Repository: NDLANO/editorial-frontend
Pull request #3021: Add dialog for editing AI prompts
Author: login: amatho
Created: 2025-05-16T08:13:39Z | Merged: 2025-05-23T07:53:19Z

Fixes NDLANO/Issues#4345


Diff:
diff --git a/src/components/AiPromptDialog.tsx b/src/components/AiPromptDialog.tsx
new file mode 100644
index 0000000000..2f9f9a726f
--- /dev/null
+++ b/src/components/AiPromptDialog.tsx
@@ -0,0 +1,226 @@
+/**
+ * Copyright (c) 2025-present, NDLA.
+ *
+ * This source code is licensed under the GPLv3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Portal } from "@ark-ui/react";
+import { FileListLine } from "@ndla/icons";
+import {
+  Button,
+  DialogBody,
+  DialogCloseTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogRoot,
+  DialogRootProps,
+  DialogTitle,
+  FieldHelper,
+  FieldLabel,
+  FieldRoot,
+  FieldTextArea,
+  Heading,
+  Spinner,
+  SwitchControl,
+  SwitchHiddenInput,
+  SwitchLabel,
+  SwitchRoot,
+  SwitchThumb,
+  Text,
+} from "@ndla/primitives";
+import { HStack, Stack, styled } from "@ndla/styled-system/jsx";
+import { DialogCloseButton } from "./DialogCloseButton";
+import { FormActionsContainer } from "./FormikForm";
+import { PromptVariables } from "../interfaces";
+import { useGenerateAIMutation } from "../modules/llm/llmMutations";
+import { useDefaultAiPrompts } from "../modules/llm/llmQueries";
+import { NdlaErrorPayload } from "../util/resolveJsonOrRejectWithError";
+
+const CustomPromptsContainer = styled(Stack, {
+  base: {
+    marginBlockEnd: "small",
+  },
+});
+
+const StyledText = styled(Text, {
+  base: {
+    border: "1px solid",
+    borderColor: "stroke.default",
+    borderRadius: "small",
+    padding: "small",
+    whiteSpace: "pre-wrap",
+  },
+});
+
+const trimIndent = (text: string) =>
+  text
+    .split("\n")
+    .map((t) => t.trim())
+    .filter(Boolean)
+    .join(" ");
+
+type BaseProps = {
+  promptVariables: PromptVariables | (() => PromptVariables);
+  language: string;
+  maxTokens?: number;
+  onInsert?: (generatedText: string) => void;
+  onAppend?: (generatedText: string) => void;
+  onReplace?: (generatedText: string) => void;
+};
+
+const PromptDialogContent = ({
+  promptVariables: promptVariablesProp,
+  language,
+  maxTokens,
+  onInsert,
+  onAppend,
+  onReplace,
+}: BaseProps) => {
+  const { t } = useTranslation();
+  const [customPromptChecked, setCustomPromptChecked] = useState(false);
+  const [rolePrompt, setRolePrompt] = useState("");
+  const [instructionsPrompt, setInstructionsPrompt] = useState("");
+  const [generatedText, setGeneratedText] = useState("");
+  const [error, setError] = useState<string | undefined>(undefined);
+  const { mutateAsync, isPending: mutateIsPending } = useGenerateAIMutation<PromptVariables>();
+  const promptVariables = typeof promptVariablesProp === "function" ? promptVariablesProp() : promptVariablesProp;
+  const defaultPromptsQuery = useDefaultAiPrompts(promptVariables.type, language);
+
+  useEffect(() => {
+    if (defaultPromptsQuery.data) {
+      if (rolePrompt === "") setRolePrompt(trimIndent(defaultPromptsQuery.data.role));
+      if (instructionsPrompt === "") setInstructionsPrompt(trimIndent(defaultPromptsQuery.data.instructions));
+    }
+  }, [defaultPromptsQuery.data, rolePrompt, instructionsPrompt]);
+
+  const fetchAiGeneratedText = async () => {
+    setError(undefined);
+    mutateAsync({
+      ...promptVariables,
+      language,
+      max_tokens: maxTokens,
+      role: rolePrompt.trim() ? rolePrompt : undefined,
+      instructions: instructionsPrompt.trim() ? instructionsPrompt : undefined,
+    })
+      .then((res) => setGeneratedText(res))
+      .catch((err: NdlaErrorPayload) =>
+        setError(t(`textGeneration.failed`, { type: promptVariables.type, error: err.messages })),
+      );
+  };
+
+  return (
+    <DialogContent>
+      <DialogHeader>
+        <DialogTitle>{t("textGeneration.dialogTitle", { type: promptVariables.type })}</DialogTitle>
+        <DialogCloseButton />
+      </DialogHeader>
+      <DialogBody>
+        <CustomPromptsContainer>
+          <FieldRoot>
+            <SwitchRoot
+              checked={customPromptChecked}
+              onCheckedChange={(details) => setCustomPromptChecked(details.checked)}
+            >
+              <SwitchLabel>{t("textGeneration.customPrompts.switchLabel")}</SwitchLabel>
+              <SwitchControl>
+                <SwitchThumb />
+              </SwitchControl>
+              <SwitchHiddenInput />
+            </SwitchRoot>
+          </FieldRoot>
+          {customPromptChecked ? (
+            defaultPromptsQuery.isPending ? (
+              <Spinner size="large" />
+            ) : (
+              <>
+                <FieldRoot>
+                  <FieldLabel>{t("textGeneration.customPrompts.roleLabel")}</FieldLabel>
+                  <FieldHelper>{t("textGeneration.customPrompts.roleHelper")}</FieldHelper>
+                  <FieldTextArea value={rolePrompt} onChange={(e) => setRolePrompt(e.target.value)} />
+                </FieldRoot>
+                <FieldRoot>
+                  <FieldLabel>{t("textGeneration.customPrompts.instructionsLabel")}</FieldLabel>
+                  <FieldHelper>{t("textGeneration.customPrompts.instructionsHelper")}</FieldHelper>
+                  <FieldTextArea value={instructionsPrompt} onChange={(e) => setInstructionsPrompt(e.target.value)} />
+                </FieldRoot>
+              </>
+            )
+          ) : null}
+        </CustomPromptsContainer>
+        {promptVariables.type === "alternativePhrasing" && <StyledText>{promptVariables.text}</StyledText>}
+        <Button size="small" onClick={fetchAiGeneratedText} loading={mutateIsPending}>
+          {t("textGeneration.generateButton", { type: promptVariables.type })}
+          <FileListLine />
+        </Button>
+        <Heading asChild consumeCss textStyle="label.medium">
+          <h2>{t("textGeneration.suggestedText", { type: promptVariables.type })}</h2>
+        </Heading>
+        <StyledText>{generatedText}</StyledText>
+        {error ? (
+          <Text textStyle="label.small" color="text.error">
+            {error}
+          </Text>
+        ) : null}
+        <HStack justify="space-between">
+          <DialogCloseTrigger asChild>
+            <Button size="small" variant="secondary">
+              {t("dialog.close")}
+            </Button>
+          </DialogCloseTrigger>
+          <FormActionsContainer>
+            {!!onInsert && (
+              <DialogCloseTrigger asChild>
+                <Button size="small" onClick={() => onInsert(generatedText)} disabled={!generatedText}>
+                  {t("textGeneration.insert")}
+                </Button>
+              </DialogCloseTrigger>
+            )}
+            {!!onAppend && (
+              <DialogCloseTrigger asChild>
+                <Button size="small" onClick={() => onAppend(generatedText)} disabled={!generatedText}>
+                  {t("textGeneration.append")}
+                </Button>
+              </DialogCloseTrigger>
+            )}
+            {!!onReplace && (
+              <DialogCloseTrigger asChild>
+                <Button size="small" onClick={() => onReplace(generatedText)} disabled={!generatedText}>
+                  {t("textGeneration.replace")}
+                </Button>
+              </DialogCloseTrigger>
+            )}
+          </FormActionsContainer>
+        </HStack>
+      </DialogBody>
+    </DialogContent>
+  );
+};
+
+export const AiPromptDialog = ({
+  promptVariables,
+  language,
+  maxTokens,
+  children,
+  onInsert,
+  onAppend,
+  onReplace,
+  ...rest
+}: DialogRootProps & BaseProps) => (
+  <DialogRoot {...rest}>
+    {children}
+    <Portal>
+      <PromptDialogContent
+        promptVariables={promptVariables}
+        language={language}
+        maxTokens={maxTokens}
+        onInsert={onInsert}
+        onAppend={onAppend}
+        onReplace={onReplace}
+      />
+    </Portal>
+  </DialogRoot>
+);
diff --git a/src/components/SlateEditor/plugins/framedContent/SlateFramedContent.tsx b/src/components/SlateEditor/plugins/framedContent/SlateFramedContent.tsx
index faedeb3f5b..7bd464e20b 100644
--- a/src/components/SlateEditor/plugins/framedContent/SlateFramedContent.tsx
+++ b/src/components/SlateEditor/plugins/framedContent/SlateFramedContent.tsx
@@ -12,18 +12,16 @@ import { Editor, NodeEntry, Transforms } from "slate";
 import { ReactEditor, RenderElementProps } from "slate-react";
 import { PARAGRAPH_ELEMENT_TYPE } from "@ndla/editor";
 import { BrushLine, CopyrightLine, FileListLine } from "@ndla/icons";
-import { IconButton } from "@ndla/primitives";
+import { DialogTrigger, IconButton } from "@ndla/primitives";
 import { styled } from "@ndla/styled-system/jsx";
 import { ContentTypeFramedContent, EmbedWrapper } from "@ndla/ui";
 import { FramedContentElement } from "./framedContentTypes";
 import { isFramedContentElement } from "./queries/framedContentQueries";
 import { AI_ACCESS_SCOPE } from "../../../../constants";
-import { useMessages } from "../../../../containers/Messages/MessagesProvider";
 import { useSession } from "../../../../containers/Session/SessionProvider";
-import { ReflectionVariables } from "../../../../modules/llm/llmApiTypes";
-import { useGenerateAIMutation } from "../../../../modules/llm/llmMutations";
+import { ReflectionVariables } from "../../../../interfaces";
 import { editorValueToPlainText } from "../../../../util/articleContentConverter";
-import { NdlaErrorPayload } from "../../../../util/resolveJsonOrRejectWithError";
+import { AiPromptDialog } from "../../../AiPromptDialog";
 import { useArticleContentType } from "../../../ContentTypeProvider";
 import DeleteButton from "../../../DeleteButton";
 import MoveContentButton from "../../../MoveContentButton";
@@ -48,9 +46,7 @@ const SlateFramedContent = (props: Props) => {
   const { element, editor, attributes, children } = props;
   const { t } = useTranslation();
   const { userPermissions } = useSession();
-  const { createMessage } = useMessages();
   const language = useArticleLanguage();
-  const generateReflectionMutation = useGenerateAIMutation<ReflectionVariables>();
   const variant = element.data?.variant ?? "neutral";
   const contentType = useArticleContentType();
   const hasAIAccess = userPermissions?.includes(AI_ACCESS_SCOPE);
@@ -89,51 +85,45 @@ const SlateFramedContent = (props: Props) => {
     Transforms.insertNodes(editor, defaultCopyrightBlock(), { at: path.concat(node.children.length) });
   };
 
-  const generateQuestions = async () => {
+  const getPromptVariables = (): ReflectionVariables => {
     // TODO: Handle nested information and metadata from embeds
     const articleText = editorValueToPlainText(editor.children);
+    return {
+      type: "reflection",
+      text: articleText,
+    };
+  };
 
-    await generateReflectionMutation
-      .mutateAsync({
-        type: "reflection",
-        text: articleText,
-        language: t(`languages.${language}`),
-      })
-      .then((res) => {
-        const [node, path] = Editor.node(
-          editor,
-          ReactEditor.findPath(editor, element),
-        ) as NodeEntry<FramedContentElement>;
+  const onInsertReflectionQuestions = (generatedText: string) => {
+    const [node, path] = Editor.node(editor, ReactEditor.findPath(editor, element)) as NodeEntry<FramedContentElement>;
 
-        Transforms.insertNodes(
-          editor,
-          { type: PARAGRAPH_ELEMENT_TYPE, children: [{ text: res }] },
-          { at: path.concat(node.children.length) },
-        );
-      })
-      .catch((err: NdlaErrorPayload) =>
-        createMessage({
-          message: t("textGeneration.failed.reflection", { error: err.messages }),
-          severity: "danger",
-          timeToLive: 0,
-        }),
-      );
+    Transforms.insertNodes(
+      editor,
+      { type: PARAGRAPH_ELEMENT_TYPE, children: [{ text: generatedText }] },
+      { at: path.concat(node.children.length) },
+    );
   };
 
   return (
     <EmbedWrapper draggable {...attributes}>
       <FigureButtons contentEditable={false}>
         {hasAIAccess ? (
-          <IconButton
-            variant={variant === "colored" ? "primary" : "secondary"}
-            size="small"
-            title={t("textGeneration.generate.reflection")}
-            aria-label={t("textGeneration.generate.reflection")}
-            onClick={generateQuestions}
-            loading={generateReflectionMutation.isPending}
+          <AiPromptDialog
+            promptVariables={getPromptVariables}
+            language={language}
+            onInsert={onInsertReflectionQuestions}
           >
-            <FileListLine />
-          </IconButton>
+            <DialogTrigger asChild>
+              <IconButton
+                variant={variant === "colored" ? "primary" : "secondary"}
+                size="small"
+                title={t("textGeneration.generateButton", { type: "reflection" })}
+                aria-label={t("textGeneration.generateButton", { type: "reflection" })}
+              >
+                <FileListLine />
+              </IconButton>
+            </DialogTrigger>
+          </AiPromptDialog>
         ) : undefined}
         {!hasSlateCopyright && (
           <IconButton
diff --git a/src/components/SlateEditor/plugins/rephrase/Rephrase.tsx b/src/components/SlateEditor/plugins/rephrase/Rephrase.tsx
index 742a950402..7eed61b2aa 100644
--- a/src/components/SlateEditor/plugins/rephrase/Rephrase.tsx
+++ b/src/components/SlateEditor/plugins/rephrase/Rephrase.tsx
@@ -6,34 +6,14 @@
  *
  */
 
-import { useCallback, useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
+import { useCallback, useMemo } from "react";
 import { Editor, Path, Node, Transforms } from "slate";
 import { ReactEditor, RenderElementProps } from "slate-react";
-import { Portal } from "@ark-ui/react";
 import { PARAGRAPH_ELEMENT_TYPE } from "@ndla/editor";
-import { FileListLine } from "@ndla/icons";
-import {
-  Button,
-  DialogBackdrop,
-  DialogBody,
-  DialogCloseTrigger,
-  DialogContent,
-  DialogHeader,
-  DialogRoot,
-  DialogTitle,
-  Heading,
-  Text,
-} from "@ndla/primitives";
-import { HStack, styled } from "@ndla/styled-system/jsx";
-import { AlternativePhrasingVariables } from "../../../../modules/llm/llmApiTypes";
-import { useGenerateAIMutation } from "../../../../modules/llm/llmMutations";
-import { NdlaErrorPayload } from "../../../../util/resolveJsonOrRejectWithError";
-import { DialogCloseButton } from "../../../DialogCloseButton";
-import { FormActionsContainer } from "../../../FormikForm";
 import { useArticleLanguage } from "../../ArticleLanguageProvider";
 import { isRephraseElement } from "./queries/rephraseQueries";
 import { RephraseElement } from "./rephraseTypes";
+import { AiPromptDialog } from "../../../AiPromptDialog";
 import mergeLastUndos from "../../utils/mergeLastUndos";
 
 interface Props extends RenderElementProps {
@@ -41,21 +21,8 @@ interface Props extends RenderElementProps {
   editor: Editor;
 }
 
-const StyledText = styled(Text, {
-  base: {
-    border: "1px solid",
-    borderColor: "stroke.default",
-    borderRadius: "small",
-    padding: "small",
-  },
-});
-
 export const Rephrase = ({ attributes, editor, element, children }: Props) => {
-  const { t } = useTranslation();
   const language = useArticleLanguage();
-  const [generatedText, setGeneratedText] = useState<string | undefined>(undefined);
-  const [error, setError] = useState<string | undefined>(undefined);
-  const phrasingMutation = useGenerateAIMutation<AlternativePhrasingVariables>();
 
   // TODO Handle marks and inlines in query.
   const currentText = useMemo(() => Node.string(element), [element]);
@@ -72,20 +39,7 @@ export const Rephrase = ({ attributes, editor, element, children }: Props) => {
     setTimeout(() => ReactEditor.focus(editor), 0);
   }, [editor, element]);
 
-  const fetchAiGeneratedText = async () => {
-    setError(undefined);
-    await phrasingMutation
-      .mutateAsync({
-        type: "alternativePhrasing",
-        text: currentText,
-        excerpt: currentText,
-        language: language,
-      })
-      .then((res) => setGeneratedText(res))
-      .catch((err: NdlaErrorPayload) => setError(t("textGeneration.failed.variant", { error: err.messages })));
-  };
-
-  const onReplace = () => {
+  const onReplace = (generatedText: string) => {
     if (generatedText) {
       editor.insertText(generatedText);
       mergeLastUndos(editor);
@@ -93,7 +47,7 @@ export const Rephrase = ({ attributes, editor, element, children }: Props) => {
     onClose();
   };
 
-  const onAppend = () => {
+  const onAppend = (generatedText: string) => {
     if (generatedText) {
       const [entry] = editor.nodes({ match: isRephraseElement });
       const [_node, path] = entry;
@@ -104,48 +58,19 @@ export const Rephrase = ({ attributes, editor, element, children }: Props) => {
   };
 
   return (
-    <DialogRoot defaultOpen onExitComplete={onClose}>
-      <Portal>
-        <DialogBackdrop />
-        <DialogContent>
-          <DialogHeader>
-            <DialogTitle>{t("textGeneration.alternativeText")}</DialogTitle>
-            <DialogCloseButton />
-          </DialogHeader>
-          <DialogBody>
-            <StyledText>{currentText}</StyledText>
-            <Button size="small" onClick={fetchAiGeneratedText} loading={phrasingMutation.isPending}>
-              {t("textGeneration.generate.variant")}
-              <FileListLine />
-            </Button>
-            <Heading asChild consumeCss textStyle="label.medium">
-              <h2>{t("textGeneration.suggestedText")}</h2>
-            </Heading>
-            <StyledText>{generatedText}</StyledText>
-            {error ? (
-              <Text textStyle="label.small" color="text.error">
-                {error}
-              </Text>
-            ) : null}
-            <HStack justify="space-between">
-              <DialogCloseTrigger asChild>
-                <Button size="small" variant="secondary">
-                  {t("dialog.close")}
-                </Button>
-              </DialogCloseTrigger>
-              <FormActionsContainer>
-                <Button size="small" onClick={onAppend} disabled={!generatedText}>
-                  {t("textGeneration.add")}
-                </Button>
-                <Button size="small" onClick={onReplace} disabled={!generatedText}>
-                  {t("textGeneration.replace")}
-                </Button>
-              </FormActionsContainer>
-            </HStack>
-          </DialogBody>
-        </DialogContent>
-      </Portal>
+    <AiPromptDialog
+      defaultOpen
+      promptVariables={{
+        type: "alternativePhrasing",
+        text: currentText,
+        excerpt: currentText,
+      }}
+      language={language}
+      onExitComplete={onClose}
+      onReplace={onReplace}
+      onAppend={onAppend}
+    >
       <span {...attributes}>{children}</span>
-    </DialogRoot>
+    </AiPromptDialog>
   );
 };
diff --git a/src/containers/AudioUploader/components/AudioManuscript.tsx b/src/containers/AudioUploader/components/AudioManuscript.tsx
index aac6ad4231..a35ad130e4 100644
--- a/src/containers/AudioUploader/components/AudioManuscript.tsx
+++ b/src/containers/AudioUploader/components/AudioManuscript.tsx
@@ -136,7 +136,7 @@ const AudioManuscript = ({ audio, audioLanguage = "no" }: AudioManuscriptProps)
         helpers.setValue(editorContent, true);
         setStatus({ status: MANUSCRIPT_EDITOR });
       } else if (transcript?.data?.status === "FAILED") {
-        createMessage({ message: t("textGeneration.failed.transcription"), severity: "danger", timeToLive: 0 });
+        createMessage({ message: t("textGeneration.failedTranscription"), severity: "danger", timeToLive: 0 });
       } else {
         const name = audio.audioFile.url?.split("audio/files/")[1];
         await postAudioTranscriptionMutation
@@ -160,7 +160,7 @@ const AudioManuscript = ({ audio, audioLanguage = "no" }: AudioManuscriptProps)
       setStatus({ status: MANUSCRIPT_EDITOR });
     } else if (polledData?.status === "FAILED" && isPolling) {
       setIsPolling(false);
-      createMessage({ message: t("textGeneration.failed.transcription"), severity: "danger", timeToLive: 0 });
+      createMessage({ message: t("textGeneration.failedTranscription"), severity: "danger", timeToLive: 0 });
     }
   }, [createMessage, helpers, isPolling, polledData?.status, polledData?.transcription, setStatus, t]);
 
@@ -191,7 +191,7 @@ const AudioManuscript = ({ audio, audioLanguage = "no" }: AudioManuscriptProps)
               disabled={!(values.audioFile.storedFile || values.audioFile.newFile)}
               loading={isPolling || fetchAudioTranscriptQuery.isLoading}
             >
-              {t("textGeneration.generate.transcription")}
+              {t("textGeneration.generateTranscription")}
               <FileListLine />
             </Button>
           ) : undefined}
diff --git a/src/containers/FormikForm/MetaDataField.tsx b/src/containers/FormikForm/MetaDataField.tsx
index 6dde4444f0..b2ce54251f 100644
--- a/src/containers/FormikForm/MetaDataField.tsx
+++ b/src/containers/FormikForm/MetaDataField.tsx
@@ -15,6 +15,7 @@ import {
   Button,
   ComboboxItem,
   ComboboxItemText,
+  DialogTrigger,
   FieldErrorMessage,
   FieldHelper,
   FieldLabel,
@@ -34,6 +35,7 @@ import { TagSelectorLabel, TagSelectorRoot, useTagSelectorTranslations } from "@
 import { MetaImageSearch } from ".";
 import { ArticleFormType } from "./articleFormHooks";
 import { GenericComboboxItemIndicator } from "../../components/abstractions/Combobox";
+import { AiPromptDialog } from "../../components/AiPromptDialog";
 import { FieldWarning } from "../../components/Form/FieldWarning";
 import { FormRemainingCharacters } from "../../components/Form/FormRemainingCharacters";
 import { SearchTagsContent } from "../../components/Form/SearchTagsContent";
@@ -43,13 +45,10 @@ import { FormContent } from "../../components/FormikForm";
 import PlainTextEditor from "../../components/SlateEditor/PlainTextEditor";
 import { textTransformPlugin } from "../../components/SlateEditor/plugins/textTransform";
 import { AI_ACCESS_SCOPE, DRAFT_ADMIN_SCOPE } from "../../constants";
+import { MetaDescriptionVariables, SummaryVariables } from "../../interfaces";
 import { useDraftSearchTags } from "../../modules/draft/draftQueries";
-import { MetaDescriptionVariables, SummaryVariables } from "../../modules/llm/llmApiTypes";
-import { useGenerateAIMutation } from "../../modules/llm/llmMutations";
 import { inlineContentToEditorValue } from "../../util/articleContentConverter";
-import { NdlaErrorPayload } from "../../util/resolveJsonOrRejectWithError";
 import useDebounce from "../../util/useDebounce";
-import { useMessages } from "../Messages/MessagesProvider";
 import { useSession } from "../Session/SessionProvider";
 
 const StyledFormRemainingCharacters = styled(FormRemainingCharacters, {
@@ -71,7 +70,6 @@ const METADATA_EDITOR = "editor-metadata";
 const MetaDataField = ({ articleLanguage, showCheckbox, checkboxAction }: Props) => {
   const { t } = useTranslation();
   const { userPermissions } = useSession();
-  const { createMessage } = useMessages();
   const tagSelectorTranslations = useTagSelectorTranslations();
   const plugins = [textTransformPlugin];
   const [inputQuery, setInputQuery] = useState<string>("");
@@ -88,8 +86,6 @@ const MetaDataField = ({ articleLanguage, showCheckbox, checkboxAction }: Props)
       placeholderData: (prev) => prev,
     },
   );
-  const generateSummaryMutation = useGenerateAIMutation<SummaryVariables>();
-  const generateMetaDescriptionMutation = useGenerateAIMutation<MetaDescriptionVariables>();
 
   const collection = useMemo(() => {
     return createListCollection({
@@ -99,52 +95,34 @@ const MetaDataField = ({ articleLanguage, showCheckbox, checkboxAction }: Props)
     });
   }, [searchTagsQuery.data?.results]);
 
-  const onClickMetaDescription = async (helpers: FieldHelperProps<Descendant[]>) => {
+  const getMetaDescriptionPromptVariables = (): MetaDescriptionVariables => {
     const articleTitle = values.title.map((val) => Node.string(val)).join(" ");
     const articleContent = values.content.map((val) => Node.string(val)).join(" ");
-
-    await generateMetaDescriptionMutation
-      .mutateAsync({
-        type: "metaDescription",
-        text: articleContent,
-        title: articleTitle,
-        language: t(`languages.${articleLanguage}`),
-      })
-      .then((res) => {
-        helpers.setValue(inlineContentToEditorValue(res, true), true);
-        setStatus({ status: METADATA_EDITOR });
-      })
-      .catch((e: NdlaErrorPayload) =>
-        createMessage({
-          message: t("textGeneration.failed.metaDescription", { error: e.messages }),
-          severity: "danger",
-          timeToLive: 0,
-        }),
-      );
+    return {
+      type: "metaDescription",
+      text: articleContent,
+      title: articleTitle,
+    };
   };
 
-  const onClickGenerateSummary = async () => {
+  const getSummaryPromptVariables = (): SummaryVariables => {
     const articleTitle = values.title.map((val) => Node.string(val)).join(" ");
     const articleContent = values.content.map((val) => Node.string(val)).join(" ");
+    return {
+      type: "summary",
+      text: articleContent,
+      title: articleTitle,
+    };
+  };
+
+  const onInsertMetaDescription = (generatedText: string, helpers: FieldHelperProps<Descendant[]>) => {
+    helpers.setValue(inlineContentToEditorValue(generatedText, true), true);
+    setStatus({ status: METADATA_EDITOR });
+  };
 
-    await generateSummaryMutation
-      .mutateAsync({
-        type: "summary",
-        text: articleContent,
-        title: articleTitle,
-        language: t(`languages.${articleLanguage}`),
-      })
-      .then((res) => {
-        setSummary(inlineContentToEditorValue(res, true));
-        setStatus({ status: SUMMARY_EDITOR });
-      })
-      .catch((e: NdlaErrorPayload) =>
-        createMessage({
-          message: t("textGeneration.failed.summary", { error: e.messages }),
-          severity: "danger",
-          timeToLive: 0,
-        }),
-      );
+  const onInsertSummary = (generatedText: string) => {
+    setSummary(inlineContentToEditorValue(generatedText, true));
+    setStatus({ status: SUMMARY_EDITOR });
   };
 
   return (
@@ -206,13 +184,15 @@ const MetaDataField = ({ articleLanguage, showCheckbox, checkboxAction }: Props)
             <HStack justify="space-between">
               <FieldLabel>{t("form.metaDescription.label")}</FieldLabel>
               {userPermissions?.includes(AI_ACCESS_SCOPE) ? (
-                <Button
-                  size="small"
-                  onClick={() => onClickMetaDescription(helpers)}
-                  loading={generateMetaDescriptionMutation.isPending}
+                <AiPromptDialog
+                  promptVariables={getMetaDescriptionPromptVariables}
+                  language={articleLanguage}
+                  onInsert={(text) => onInsertMetaDescription(text, helpers)}
                 >
-                  {t("textGeneration.generate.metaDescription")}
-                </Button>
+                  <DialogTrigger asChild>
+                    <Button size="small">{t("textGeneration.generateButton", { type: "metaDescription" })}</Button>
+                  </DialogTrigger>
+                </AiPromptDialog>
               ) : null}
             </HStack>
             <FieldHelper>{t("form.metaDescription.description")}</FieldHelper>
@@ -233,9 +213,15 @@ const MetaDataField = ({ articleLanguage, showCheckbox, checkboxAction }: Props)
         <div>
           <HStack justify="space-between">
             <Text textStyle="label.medium">{t("form.articleSummary.label")}</Text>
-            <Button size="small" onClick={() => onClickGenerateSummary()} loading={generateSummaryMutation.isPending}>
-              {t("textGeneration.generate.summary")}
-            </Button>
+            <AiPromptDialog
+              promptVariables={getSummaryPromptVariables}
+              language={articleLanguage}
+              onInsert={onInsertSummary}
+            >
+              <DialogTrigger asChild>
+                <Button size="small">{t("textGeneration.generateButton", { type: "summary" })}</Button>
+              </DialogTrigger>
+            </AiPromptDialog>
           </HStack>
           <Text textStyle="label.small">{t("form.articleSummary.description")}</Text>
           <PlainTextEditor
diff --git a/src/containers/ImageUploader/components/ImageContent.tsx b/src/containers/ImageUploader/components/ImageContent.tsx
index fb4b5f7b39..123d3ea8ae 100644
--- a/src/containers/ImageUploader/components/ImageContent.tsx
+++ b/src/containers/ImageUploader/components/ImageContent.tsx
@@ -6,25 +6,34 @@
  *
  */
 
-import { FieldHelperProps, useFormikContext } from "formik";
+import { useField, useFormikContext } from "formik";
+import { useEffect, useState } from "react";
 import { useTranslation } from "react-i18next";
 import { FileListLine } from "@ndla/icons";
-import { Button, FieldLabel, FieldRoot, FieldErrorMessage, FieldTextArea } from "@ndla/primitives";
+import { Button, FieldLabel, FieldRoot, FieldErrorMessage, FieldTextArea, DialogTrigger } from "@ndla/primitives";
 import { HStack } from "@ndla/styled-system/jsx";
 import { ImageUploadFormElement } from "./ImageUploadFormElement";
+import { AiPromptDialog } from "../../../components/AiPromptDialog";
 import { FormField } from "../../../components/FormField";
 import { FormContent } from "../../../components/FormikForm";
 import { AI_ACCESS_SCOPE } from "../../../constants";
 import { useSession } from "../../../containers/Session/SessionProvider";
-import { AltTextVariables } from "../../../modules/llm/llmApiTypes";
-import { useGenerateAIMutation } from "../../../modules/llm/llmMutations";
+import { AltTextVariables } from "../../../interfaces";
 import { TitleField } from "../../FormikForm";
-import { useMessages } from "../../Messages/MessagesProvider";
 import { ImageFormikType } from "../imageTransformers";
 
 const ALLOWED_IMAGE_TYPES = "(jpe?g|png|gif)";
 const IMAGE_TYPE_REGEX = new RegExp(ALLOWED_IMAGE_TYPES);
 const IMAGE_IDENTIFIER_REGEX = new RegExp(`data:image/${ALLOWED_IMAGE_TYPES};base64,`);
+
+const readImageText = (imageBlob: Blob): Promise<string> =>
+  new Promise((resolve, reject) => {
+    const fileReader = new FileReader();
+    fileReader.onloadend = () => resolve(fileReader.result?.toString() ?? "");
+    fileReader.onerror = reject;
+    fileReader.readAsDataURL(imageBlob);
+  });
+
 interface Props {
   language: string;
 }
@@ -33,58 +42,37 @@ const ImageContent = ({ language }: Props) => {
   const { t } = useTranslation();
   const { userPermissions } = useSession();
   const { values } = useFormikContext<ImageFormikType>();
-  const { createMessage } = useMessages();
-  const generateAltTextMutation = useGenerateAIMutation<AltTextVariables>();
+  const [image, setImage] = useState<AltTextVariables["image"] | undefined>(undefined);
+  const [altTextField, altTextMeta, altTextHelpers] = useField("alttext");
 
-  const generateAltText = async (helpers: FieldHelperProps<string | undefined>) => {
-    if (!values.imageFile) {
-      helpers.setError(t("textGeneration.errorImage"));
-      return;
-    }
+  useEffect(() => {
+    const getImagePromptVariables = async () => {
+      if (!values.imageFile) return;
 
-    let image;
-    try {
-      if (typeof values.imageFile === "string") {
-        // We use the timestamp to avoid caching of the `imageFile` url in the browser
-        const timestamp = new Date().getTime();
-        const result = await fetch(values.filepath || `${values.imageFile}?width=1500&ts=${timestamp}`);
-        image = await result.blob();
-      } else {
-        image = values.imageFile;
+      let imageBlob;
+      try {
+        if (typeof values.imageFile === "string") {
+          // We use the timestamp to avoid caching of the `imageFile` url in the browser
+          const timestamp = new Date().getTime();
+          const result = await fetch(values.filepath || `${values.imageFile}?width=1500&ts=${timestamp}`);
+          imageBlob = await result.blob();
+        } else {
+          imageBlob = values.imageFile;
+        }
+      } catch (e) {
+        altTextHelpers.setError(t("textGeneration.errorImage"));
+        return;
       }
-    } catch (e) {
-      helpers.setError(t("textGeneration.errorImage"));
-      return;
-    }
 
-    if (image) {
-      const fileReader = new FileReader();
-      fileReader.readAsDataURL(image);
-      fileReader.onloadend = async () => {
-        const imageText = fileReader.result?.toString() ?? "";
+      if (imageBlob) {
+        const imageText = await readImageText(imageBlob);
         // All images from the filereader is appended with a data identifier string. https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsDataURL
         const base64 = imageText.replace(IMAGE_IDENTIFIER_REGEX, "");
-        await generateAltTextMutation
-          .mutateAsync({
-            type: "alttext",
-            image: {
-              base64: base64,
-              fileType: image.type,
-            },
-            max_tokens: 2000,
-            language: language,
-          })
-          .then((res) => helpers.setValue(res, true))
-          .catch((err) =>
-            createMessage({
-              message: t("textGeneration.failed.alttext", { error: err.messages }),
-              severity: "danger",
-              timeToLive: 0,
-            }),
-          );
-      };
-    }
-  };
+        setImage({ base64, fileType: imageBlob.type });
+      }
+    };
+    getImagePromptVariables();
+  }, [values.imageFile, values.filepath, altTextHelpers, t]);
 
   return (
     <FormContent>
@@ -99,29 +87,32 @@ const ImageContent = ({ language }: Props) => {
           </FieldRoot>
         )}
       </FormField>
-      <FormField name="alttext">
-        {({ field, meta, helpers }) => (
-          <FieldRoot invalid={!!meta.error}>
-            <HStack justify="space-between">
-              <FieldLabel>{t("form.image.alt.label")}</FieldLabel>
-              {userPermissions?.includes(AI_ACCESS_SCOPE) && !!values.contentType?.match(IMAGE_TYPE_REGEX) ? (
-                <Button
-                  onClick={() => generateAltText(helpers)}
-                  size="small"
-                  loading={generateAltTextMutation.isPending}
-                  disabled={!values.imageFile}
-                >
-                  {t("textGeneration.generate.alttext")}
+      <FieldRoot invalid={!!altTextMeta.error}>
+        <HStack justify="space-between">
+          <FieldLabel>{t("form.image.alt.label")}</FieldLabel>
+          {userPermissions?.includes(AI_ACCESS_SCOPE) && !!values.contentType?.match(IMAGE_TYPE_REGEX) && !!image ? (
+            <AiPromptDialog
+              promptVariables={{
+                type: "altText",
+                image,
+              }}
+              language={language}
+              maxTokens={2000}
+              onInsert={(text) => altTextHelpers.setValue(text)}
+            >
+              <DialogTrigger asChild>
+                <Button size="small" disabled={!values.imageFile}>
+                  {t("textGeneration.generateButton", { type: "altText" })}
                   <FileListLine />
                 </Button>
-              ) : null}
-            </HStack>
-            {/*  TODO: FieldTextArea does not resize when setting generated alttext */}
-            <FieldTextArea placeholder={t("form.image.alt.placeholder")} {...field} />
-            <FieldErrorMessage>{meta.error}</FieldErrorMessage>
-          </FieldRoot>
-        )}
-      </FormField>
+              </DialogTrigger>
+            </AiPromptDialog>
+          ) : null}
+        </HStack>
+        {/*  TODO: FieldTextArea does not resize when setting generated alttext */}
+        <FieldTextArea placeholder={t("form.image.alt.placeholder")} {...altTextField} />
+        <FieldErrorMessage>{altTextMeta.error}</FieldErrorMessage>
+      </FieldRoot>
     </FormContent>
   );
 };
diff --git a/src/interfaces.ts b/src/interfaces.ts
index 30af11f1e6..cac48b4c4c 100644
--- a/src/interfaces.ts
+++ b/src/interfaces.ts
@@ -205,3 +205,65 @@ export interface SearchParams {
   "filter-inactive"?: boolean;
   includeCopyrighted?: boolean;
 }
+
+export type PromptType = PromptVariables["type"];
+
+export type PromptVariables =
+  | SummaryVariables
+  | AltTextVariables
+  | AlternativePhrasingVariables
+  | MetaDescriptionVariables
+  | ReflectionVariables;
+
+export interface SummaryVariables {
+  type: "summary";
+  text: string;
+  title: string;
+}
+
+export interface AltTextVariables {
+  type: "altText";
+  image: {
+    fileType: string;
+    base64: string;
+  };
+}
+
+export interface AlternativePhrasingVariables {
+  type: "alternativePhrasing";
+  text: string;
+  excerpt: string;
+}
+
+export interface MetaDescriptionVariables {
+  type: "metaDescription";
+  text: string;
+  title: string;
+}
+
+export interface ReflectionVariables {
+  type: "reflection";
+  text: string;
+}
+
+const promptTypes: PromptType[] = [
+  "summary",
+  "altText",
+  "alternativePhrasing",
+  "metaDescription",
+  "reflection",
+] as const;
+
+export const isPromptType = (type: string): type is PromptType => promptTypes.includes(type as PromptType);
+
+export type PromptPayload<T extends PromptVariables> = T & {
+  language: string;
+  role?: string;
+  instructions?: string;
+  max_tokens?: number;
+};
+
+export interface DefaultPrompts {
+  role: string;
+  instructions: string;
+}
diff --git a/src/modules/llm/llmApi.ts b/src/modules/llm/llmApi.ts
index d5294bd269..a601cc7691 100644
--- a/src/modules/llm/llmApi.ts
+++ b/src/modules/llm/llmApi.ts
@@ -6,11 +6,13 @@
  *
  */
 
+import { PromptVariables, PromptPayload, PromptType, DefaultPrompts } from "../../interfaces";
 import { fetchAuthorized } from "../../util/apiHelpers";
-import { resolveTextOrRejectWithError } from "../../util/resolveJsonOrRejectWithError";
-import { Payload } from "./llmApiTypes";
+import { resolveJsonOrRejectWithError, resolveTextOrRejectWithError } from "../../util/resolveJsonOrRejectWithError";
 
-export const fetchAIGeneratedAnswer = async (payload: Payload): Promise<string> =>
+export const fetchAIGeneratedAnswer = async <TVariables extends PromptVariables>(
+  payload: PromptPayload<TVariables>,
+): Promise<string> =>
   fetchAuthorized("/generate-ai", {
     method: "POST",
     headers: {
@@ -18,3 +20,8 @@ export const fetchAIGeneratedAnswer = async (payload: Payload): Promise<string>
     },
     body: JSON.stringify(payload),
   }).then((res) => resolveTextOrRejectWithError(res));
+
+export const fetchDefaultAiPrompts = async (type: PromptType, language: string): Promise<DefaultPrompts> =>
+  fetchAuthorized(`/default-ai-prompts?type=${type}&language=${language}`).then((res) =>
+    resolveJsonOrRejectWithError(res),
+  );
diff --git a/src/modules/llm/llmApiTypes.ts b/src/modules/llm/llmApiTypes.ts
deleted file mode 100644
index c4242e55b0..0000000000
--- a/src/modules/llm/llmApiTypes.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * Copyright (c) 2025-present, NDLA.
- *
- * This source code is licensed under the GPLv3 license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-export type Payload =
-  | SummaryVariables
-  | AltTextVariables
-  | AlternativePhrasingVariables
-  | MetaDescriptionVariables
-  | ReflectionVariables;
-
-interface BaseVariables {
-  language: string;
-  max_tokens?: number;
-}
-
-export interface AlternativePhrasingVariables extends BaseVariables {
-  type: "alternativePhrasing";
-  text: string;
-  excerpt: string;
-}
-
-export interface ReflectionVariables extends BaseVariables {
-  type: "reflection";
-  text: string;
-}
-
-export interface MetaDescriptionVariables extends BaseVariables {
-  type: "metaDescription";
-  text: string;
-  title: string;
-}
-
-export interface AltTextVariables extends BaseVariables {
-  type: "alttext";
-  image: {
-    fileType: string;
-    base64: string;
-  };
-}
-
-export interface SummaryVariables extends BaseVariables {
-  type: "summary";
-  text: string;
-  title: string;
-}
diff --git a/src/modules/llm/llmMutations.ts b/src/modules/llm/llmMutations.ts
index cde853c8b7..57cda68add 100644
--- a/src/modules/llm/llmMutations.ts
+++ b/src/modules/llm/llmMutations.ts
@@ -8,12 +8,12 @@
 
 import { DefaultError, UseMutationOptions, useMutation } from "@tanstack/react-query";
 import { fetchAIGeneratedAnswer } from "./llmApi";
-import { Payload } from "./llmApiTypes";
+import { PromptVariables, PromptPayload } from "../../interfaces";
 
-export const useGenerateAIMutation = <TVariables extends Payload>(
-  options?: UseMutationOptions<string, DefaultError, TVariables>,
+export const useGenerateAIMutation = <TVariables extends PromptVariables>(
+  options?: UseMutationOptions<string, DefaultError, PromptPayload<TVariables>>,
 ) =>
-  useMutation<string, DefaultError, TVariables>({
+  useMutation({
     mutationFn: fetchAIGeneratedAnswer,
     ...options,
   });
diff --git a/src/modules/llm/llmQueries.ts b/src/modules/llm/llmQueries.ts
new file mode 100644
index 0000000000..ad0ebec7cd
--- /dev/null
+++ b/src/modules/llm/llmQueries.ts
@@ -0,0 +1,17 @@
+/**
+ * Copyright (c) 2025-present, NDLA.
+ *
+ * This source code is licensed under the GPLv3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useQuery } from "@tanstack/react-query";
+import { fetchDefaultAiPrompts } from "./llmApi";
+import { PromptType } from "../../interfaces";
+
+export const useDefaultAiPrompts = (type: PromptType, language: string) =>
+  useQuery({
+    queryKey: [type, language],
+    queryFn: () => fetchDefaultAiPrompts(type, language),
+  });
diff --git a/src/phrases/phrases-en.ts b/src/phrases/phrases-en.ts
index 694cc1f807..06624e91ca 100644
--- a/src/phrases/phrases-en.ts
+++ b/src/phrases/phrases-en.ts
@@ -2284,28 +2284,30 @@ const phrases = {
   textGeneration: {
     error: "Something went wrong when generating text. Message from the service: {{message}}",
     errorImage: "Something went wrong. Could not process the image.",
-    summary: "Summary",
+    insert: "Insert",
     replace: "Replace",
-    add: "Add after",
+    append: "Add after",
     chosenText: "Selected text",
-    suggestedText: "Suggestions for alternative phrasing",
-    alternativeText: "Suggested phrasing",
-    generate: {
-      metaDescription: "Generate meta description",
-      reflection: "Generate reflection question",
-      summary: "Generate summary",
-      variant: "Generate suggestion",
-      alttext: "Generate alt text",
-      transcription: "Generate transcription",
-    },
-    failed: {
-      alttext: "Oops, something went wrong! Could not generate alttext. \n {{ error }}",
-      reflection: "Oops, something went wrong! Could not generate reflection. \n {{ error }}",
-      summary: "Oops, something went wrong! Could not generate summary. \n {{ error }}",
-      transcription: "Oops, something went wrong! Could not generate transcription.",
-      metaDescription: "Oops, something went wrong! Could not generate meta description. \n {{ error }}",
-      variant: "Oops, something went wrong! Could not generate suggestion. \n {{ error }}",
-    },
+    suggestedText: "Forslag til $t(textGeneration.types.{{type}})",
+    dialogTitle: "Generer $t(textGeneration.types.{{type}})",
+    generateButton: "Generer $t(textGeneration.types.{{type}})",
+    generateTranscription: "Generate transcription",
+    types: {
+      summary: "summary",
+      altText: "alternative text",
+      alternativePhrasing: "alternative phrasing",
+      metaDescription: "meta description",
+      reflection: "reflection questions",
+    },
+    customPrompts: {
+      switchLabel: "Custom prompts",
+      roleLabel: "Role prompt",
+      roleHelper: 'Describe the role of the AI model. E.g.: "You are a specialist in..."',
+      instructionsLabel: "Instruction prompt",
+      instructionsHelper: 'Describe the instructions for the AI model. E.g.: "Your task is to..."',
+    },
+    failed: "Oops, something went wrong! Could not generate $t(textGeneration.types.{{type}}).\n{{error}}",
+    failedTranscription: "Oops, something went wrong! Could not generate transcription.",
   },
 };
 
diff --git a/src/phrases/phrases-nb.ts b/src/phrases/phrases-nb.ts
index 8bf8ab9c40..6aa74e44b6 100644
--- a/src/phrases/phrases-nb.ts
+++ b/src/phrases/phrases-nb.ts
@@ -2283,28 +2283,30 @@ const phrases = {
   textGeneration: {
     error: "Noe gikk galt under generering av tekst. Melding fra tjenesten: {{message}}",
     errorImage: "Noe gikk galt, kunne ikke prosessere bildet.",
-    summary: "Oppsummering",
+    insert: "Sett inn",
     replace: "Erstatt",
-    add: "Legg til etter",
+    append: "Legg til etter",
     chosenText: "Valgt tekst",
-    suggestedText: "Forslag til ny formulering",
-    alternativeText: "Alternativ formulering",
-    generate: {
-      metaDescription: "Generer metabeskrivelse",
-      reflection: "Generer refleksjonsspørsmål",
-      summary: "Generer oppsummering",
-      variant: "Generer forslag",
-      alttext: "Generer alttekst",
-      transcription: "Generer transkribering",
-    },
-    failed: {
-      alttext: "Her gikk det galt! Klarte ikke å generere alttext. \n {{ error }}",
-      reflection: "Her gikk det galt! Klarte ikke å generere refleksjonsspørsmål. \n {{ error }}",
-      summary: "Her gikk det galt! Klarte ikke å generere oppsummering. \n {{ error }}",
-      transcription: "Her gikk det galt! Klarte ikke å generere transkribering.",
-      metaDescription: "Her gikk det galt! Klarte ikke å generere metabeskrivelse. \n {{ error }}",
-      variant: "Her gikk det galt! Klarte ikke å generere forslag. \n {{ error }}",
-    },
+    suggestedText: "Forslag til $t(textGeneration.types.{{type}})",
+    dialogTitle: "Generer $t(textGeneration.types.{{type}})",
+    generateButton: "Generer $t(textGeneration.types.{{type}})",
+    generateTranscription: "Generer transkripsjon",
+    types: {
+      summary: "oppsummering",
+      altText: "alternativ tekst",
+      alternativePhrasing: "alternativ formulering",
+      metaDescription: "metabeskrivelse",
+      reflection: "refleksjonsspørsmål",
+    },
+    customPrompts: {
+      switchLabel: "Egendefinerte prompter",
+      roleLabel: "Rolle-prompt",
+      roleHelper: 'Beskriv rollen til KI-modellen. F.eks.: "Du er en spesialist innen..."',
+      instructionsLabel: "Instruksjons-prompt",
+      instructionsHelper: 'Beskriv instruksjonene til KI-modellen. F.eks.: "Du har fått i oppdrag å..."',
+    },
+    failed: "Her gikk det galt! Klarte ikke å generere $t(textGeneration.types.{{type}}).\n{{error}}",
+    failedTranscription: "Her gikk det galt! Klarte ikke å generere transkripsjon.",
   },
 };
 
diff --git a/src/phrases/phrases-nn.ts b/src/phrases/phrases-nn.ts
index 69e12e880f..69aae7f9ca 100644
--- a/src/phrases/phrases-nn.ts
+++ b/src/phrases/phrases-nn.ts
@@ -2284,28 +2284,30 @@ const phrases = {
   textGeneration: {
     error: "Noko gjekk gale under generering av tekst. Melding frå tenesta: {{message}}",
     errorImage: "Noko gjekk gale, kunne ikkje prosessere biletet.",
-    summary: "Oppsummering",
+    insert: "Sett inn",
     replace: "Erstatt",
-    add: "Legg til etter",
+    append: "Legg til etter",
     chosenText: "Valt tekst",
-    suggestedText: "Forslag til ny formulering",
-    alternativeText: "Alternativ formulering",
-    generate: {
-      metaDescription: "Generer metabeskriving",
-      transcription: "Generer transkribering",
-      reflection: "Generer refleksjonsspørsmål",
-      summary: "Generer oppsummering",
-      variant: "Generer forslag",
-      alttext: "Generer alttekst",
-    },
-    failed: {
-      alttext: "Her gjekk det gale! Klarte ikkje å generere alttekst. \n {{ error }}",
-      reflection: "Her gjekk det gale! Klarte ikkje å generere refleksjonsspørsmål. \n {{ error }}",
-      summary: "Her gjekk det gale! Klarte ikkje å generere oppsummering. \n {{ error }}",
-      transcription: "Her gjekk det gale! Klarte ikkje å generere transkribering.",
-      metaDescription: "Her gjekk det gale! Klarte ikkje å generere metabeskriving. \n {{ error }}",
-      variant: "Her gjekk det gale! Klarte ikkje å generere forslag. \n {{ error }}",
-    },
+    suggestedText: "Forslag til $t(textGeneration.types.{{type}})",
+    dialogTitle: "Generer $t(textGeneration.types.{{type}})",
+    generateButton: "Generer $t(textGeneration.types.{{type}})",
+    generateTranscription: "Generer transkripsjon",
+    types: {
+      summary: "oppsummering",
+      altText: "alternativ tekst",
+      alternativePhrasing: "alternativ formulering",
+      metaDescription: "metabeskriving",
+      reflection: "refleksjonsspørsmål",
+    },
+    customPrompts: {
+      switchLabel: "Eigendefinerte prompter",
+      roleLabel: "Rolle-prompt",
+      roleHelper: 'Beskriv rolla til KI-modellen. T.d.: "Du er ein spesialist innan..."',
+      instructionsLabel: "Instruksjons-prompt",
+      instructionsHelper: 'Beskriv instruksjonane til KI-modellen. T.d.: "Du har fått i oppdrag å..."',
+    },
+    failed: "Her gjekk det gale! Klarte ikkje å generere $t(textGeneration.types.{{type}}).\n{{error}}",
+    failedTranscription: "Her gjekk det gale! Klarte ikkje å generere transkribering.",
   },
 };
 
diff --git a/src/server/api.ts b/src/server/api.ts
index 7d54598d06..1990eeb0f4 100644
--- a/src/server/api.ts
+++ b/src/server/api.ts
@@ -16,10 +16,11 @@ import errorLogger from "./logger";
 import { translateDocument } from "./translate";
 import config, { getEnvironmentVariabel } from "../config";
 import { AI_ACCESS_SCOPE, DRAFT_PUBLISH_SCOPE, DRAFT_WRITE_SCOPE } from "../constants";
-import { NdlaError } from "../interfaces";
+import { isPromptType, NdlaError } from "../interfaces";
 import { fetchMatomoStats } from "./matomo";
-import { generateAnswer, getTranscription, initializeTranscription } from "./llm";
+import { generateAnswer, getDefaultPrompts, getTranscription, initializeTranscription } from "./llm";
 import { isValidRequestBody } from "./utils";
+import { isLlmLanguageCode } from "./llmTypes";
 
 const router = express.Router();
 
@@ -187,6 +188,22 @@ const aiMiddleware = (req: Request, res: express.Response, next: express.NextFun
   }
 };
 
+router.get("/default-ai-prompts", jwtMiddleware, aiMiddleware, async (req, res) => {
+  const {
+    query: { type, language },
+  } = req;
+
+  const promptType = type as string;
+  const lang = language as string;
+  if (!isPromptType(promptType) || !isLlmLanguageCode(lang)) {
+    res.status(BAD_REQUEST).send({ error: "Invalid parameter types" });
+    return;
+  }
+
+  const defaultPrompts = getDefaultPrompts(promptType, lang);
+  res.status(OK).json(defaultPrompts);
+});
+
 router.post("/generate-ai", jwtMiddleware, aiMiddleware, async (req, res) => {
   if (!isValidRequestBody(req.body)) {
     res.status(BAD_REQUEST).send({ error: "Missing required parameters" });
diff --git a/src/server/llm.ts b/src/server/llm.ts
index 2da24ea283..31f6ee983e 100644
--- a/src/server/llm.ts
+++ b/src/server/llm.ts
@@ -7,7 +7,6 @@
  */
 
 import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime";
-import { getPromptQuery, PromptVariables } from "./llmQueries";
 import { getEnvironmentVariabel } from "../config";
 import {
   GetTranscriptionJobCommand,
@@ -16,6 +15,10 @@ import {
   StartTranscriptionJobCommand,
   TranscribeClient,
 } from "@aws-sdk/client-transcribe";
+import { llmQueryText } from "./llmQueries";
+import { PROMPTS } from "./llmPrompts";
+import { PromptType, DefaultPrompts, PromptPayload, PromptVariables } from "../interfaces";
+import { LlmLanguageCode } from "./llmTypes";
 
 const aiModelId = getEnvironmentVariabel("NDLA_AI_MODEL_ID", "test");
 const aiRegion = getEnvironmentVariabel("NDLA_AI_MODEL_REGION", "eu-west-1");
@@ -33,23 +36,28 @@ const bedRockClient = new BedrockRuntimeClient({
 });
 const textDecoder = new TextDecoder();
 
-export const generateAnswer = async (params: PromptVariables, language: string, max_tokens: number) => {
-  const promptQuery = getPromptQuery(params, language);
+export const getDefaultPrompts = (type: PromptType, language: LlmLanguageCode): DefaultPrompts => {
+  const { role, generalInstructions: instructions } = PROMPTS[language][type];
+  return { role, instructions };
+};
+
+export const generateAnswer = async (request: PromptPayload<PromptVariables>, language: string, max_tokens: number) => {
+  const { role, message } = llmQueryText(request, language);
 
   const prompt = {
     type: "text",
-    text: promptQuery,
+    text: message,
   };
 
   const content =
-    params.type === "alttext"
+    request.type === "altText"
       ? [
           {
             type: "image",
             source: {
               type: "base64",
-              media_type: params.image.fileType,
-              data: params.image.base64,
+              media_type: request.image.fileType,
+              data: request.image.base64,
             },
           },
           prompt,
@@ -65,6 +73,7 @@ export const generateAnswer = async (params: PromptVariables, language: string,
         role: "user",
       },
     ],
+    system: role,
   };
 
   const command = new InvokeModelCommand({
diff --git a/src/server/llmPrompts.ts b/src/server/llmPrompts.ts
new file mode 100644
index 0000000000..93a97f5359
--- /dev/null
+++ b/src/server/llmPrompts.ts
@@ -0,0 +1,217 @@
+/**
+ * Copyright (c) 2025-present, NDLA.
+ *
+ * This source code is licensed under the GPLv3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { PromptVariables } from "../interfaces";
+import { LlmLanguageCode, Prompt } from "./llmTypes";
+
+export type Prompts = {
+  [T in LlmLanguageCode]: {
+    [U in PromptVariables["type"]]: Prompt;
+  };
+};
+
+export const DEBUG_INSTRUCTION = {
+  nb: "Utenfor <answer>-taggen skal du levere refleksjoner på hvorfor du har valgt å inkludere de punktene du har valgt først.",
+  nn: "Utanfor <answer>-taggen skal du levere refleksjonar på kvifor du har valt å inkludere dei punkta du har valt først.",
+  en: "Outside of the <answer> tag you are to write reflections as to why you have chosen to include the selected points first.",
+};
+
+export const ERROR_INSTRUCTION = {
+  nb: "Om du ikke klarer å levere svaret på det spesifiserte <answer>-formatet skal grunnen skrives i en <ERROR>-tag.",
+  nn: "Om du ikkje klarar å levere svaret på det spesifiserte <answer>-formatet skal grunnen skrivast i ein <ERROR>-tag.",
+  en: "If you cannot deliver the answer in the given format <answer>, the reasoning should be written inside an <ERROR> tag.",
+};
+
+export const PROMPTS: Prompts = {
+  nb: {
+    summary: {
+      role: `
+        Du har lang erfaring fra utdanningssektoren.
+        Du har spesialisert deg i å oppsummere fagartikler for å gjøre dem mer tilgjengelige for et bredere publikum.`,
+      generalInstructions: `
+        Du har fått som oppdrag å hjelpe med å forbedre læringsopplevelsen for elevene.
+        For å gjøre dette må du lese gjennom og lage en oppsummering av artikler sånn at videregåendestudentene lett kan få et inntrykk av de viktigste poengene i artikkelen.`,
+      formatInstructions: `
+        Du har fått som oppdrag å lese gjennom og skrive en oppsummering til artikkelen i <draft>-taggen.
+        Tittelen til artikkelen er i <title>-taggen.
+        Oppsummeringen skal være på NB.
+        Oppsummeringen skal være på mindre enn 300 ord, og skrives i en <answer>-tag.
+        Oppsummeringen skal ikke være en liste og skal ikke inkludere tittelen.`,
+    },
+    metaDescription: {
+      role: `
+        Du har lang erfaring fra utdanningssektoren.
+        Du er spesialist i å finne gode måter å beskrive fagartikler på, slik at man fra en kort oppsummering klarer å forstå hva artiklene handler om.`,
+      generalInstructions: `
+        Du har fått som oppdrag å lese gjennom en artikkel og skrive en metabeskrivelse om den.`,
+      formatInstructions: `
+        Tittelen på artikkelen er i <title>-taggen.
+        Innholdet til artikkelen er i <draft>-taggen.
+        Metabeskrivelsen skal være skrevet på NB.
+        Metabeskrivelsen skal være på mindre enn 15 ord, og skal skrives i en <answer>-tag.`,
+    },
+    alternativePhrasing: {
+      role: `
+        Du har lang erfaring fra utdanningssektoren.
+        Du er spesialist i å renskrive fagartikler for å forbedre det tekstlige innholdet, med fokus på tydelighet og lesbarhet.`,
+      generalInstructions: `
+        Du har fått som oppdrag å foreslå en bedre formulering av et tekstutdrag.`,
+      formatInstructions: `
+        Tekstutdraget er gitt i <excerpt>-taggen.
+        Innholdet i <draft>-taggen kan brukes som kontekst, men skal ikke gjenbrukes som del av den foreslåtte omformuleringen.
+        Forslaget til en forbedret tekst skal være skrevet på NB.
+        Forslaget til en forbedret tekst skal være skrevet i en <answer>-tag.`,
+    },
+    altText: {
+      role: `
+        Du har lang erfaring fra utdanningssektoren.
+        Du er spesialist i å skrive alternative tekster for bilder for å gjøre dem tilgjengelige for alle elever.`,
+      generalInstructions: `
+        Du har fått som oppdrag å skrive en alternativ tekst for det vedlagte bildet.`,
+      formatInstructions: `
+        Teksten skal være på NB.
+        Beskrivelsen skal være på maks 125 tegn, og skrevet i en <answer>-tag.`,
+    },
+    reflection: {
+      role: `
+        Du har lang erfaring fra utdanningssektoren.
+        Du er spesialist i å analysere fagartikler for å finne gode refleksjonsspørsmål som får elevene til å tenke gjennom hva de har lest.`,
+      generalInstructions: `
+        Du har fått som oppdrag å lese gjennom en artikkel og skrive fem refleksjonsspørsmål basert på artikkelen.`,
+      formatInstructions: `
+        Artikkelen er gitt i <draft>-taggen.
+        Spørsmålene skal skrives på NB.
+        Spørsmålene skal være på et nivå som passer for elever på videregående skole.
+        Spørsmålene gis som en punktliste inne i en <answer>-tag.`,
+    },
+  },
+  nn: {
+    summary: {
+      role: `
+        Du har lang erfaring frå utdanningssektoren.
+        Du har spesialisert deg i å oppsummere fagartiklar for å gjere dei meir tilgjengeleg for eit større publikum.`,
+      generalInstructions: `
+        Du har fått som oppdrag å hjelpe med å forbetre læringsopplevelsen for elevane.
+        For å gjere dette må du lese gjennom og lage ei oppsummering av artiklar så vidaregåandestudentane lett kan få eit inntrykk av dei viktigaste poenga i artikkelen.`,
+      formatInstructions: `
+        Du har fått i oppgåve å lese gjennom og skrive ei oppsummering til artikkelen i <draft>-taggen.
+        Tittelen til artikkelen er i <title>-taggen.
+        Oppsummeringa skal vere på NN.
+        Oppsummeringa skal vere på mindre enn 300 ord, og skrives i ein <answer>-tag.
+        Oppsummeringa skal ikkje vere ein liste og skal ikkje inkludera tittelen.`,
+    },
+    metaDescription: {
+      role: `
+        Du har lang erfaring frå utdanningssektoren.
+        Du er spesialist i å finne gode måter å skildre fagartiklar på, slik at ein frå ei kort oppsummering kan forstå kva artikkelen handlar om.`,
+      generalInstructions: `
+        Du har fått i oppgåve å lese gjennom ein artikkel og skrive ein metabeskrivelse om den.`,
+      formatInstructions: `
+        Tittelen på artikkelen er i <title>-taggen.
+        Innhaldet til artikkelen er i <draft>-taggen.
+        Metabeskrivinga skal vere skrevet på NN.
+        Metabeskrivinga skal vere på mindre enn 15 ord, og skrives i ein <answer>-tag.`,
+    },
+    alternativePhrasing: {
+      role: `
+        Du har lang erfaring frå utdanningssektoren.
+        Du er spesialist i å reinskrive fagartiklar for å gjere det tekstlige innhaldet betre, med fokus på klarleik og lesbarheit.`,
+      generalInstructions: `
+        Du har fått i oppgåve å foreslå ei betre formulering av eit tekstutdrag.`,
+      formatInstructions: `
+        Tekstutdraget er gitt i <excerpt>-taggen.
+        Innhaldet i <draft>-taggen kan nyttast som kontekst, men skal ikkje gjenbrukast som del av den føreslåtte omformuleringa.
+        Forslaget til ein betre formulering skal vere skrive på NN.
+        Forslaget til ein betre formulering skal vere skrive i ein <answer>-tag.`,
+    },
+    altText: {
+      role: `
+        Du har lang erfaring frå utdanningssektoren.
+        Du er spesialist i å skrive alternative tekstar for bilete for å gjere dei tilgjengelege for alle elevar.`,
+      generalInstructions: `
+        Du har fått som oppgåve å skrive ein alternativ tekst for det vedlagte biletet.`,
+      formatInstructions: `
+        Teksta skal vere på NN.
+        Beskrivinga skal vere på maks 125 teikn, og skrive i ein <answer>-tag.`,
+    },
+    reflection: {
+      role: `
+        Du har lang erfaring frå utdanningssektoren.
+        Du er spesialist i å analysere fagartiklar for å finne gode refleksjonsspørsmål som får elevane til å tenkje gjennom kva dei har lest.`,
+      generalInstructions: `
+        Du har fått i oppgåve å lese gjennom ein artikkel og skrive fem refleksjonsspørsmål basert på artikkelen.`,
+      formatInstructions: `
+        Artikkelen er gitt i <draft>-taggen.
+        Spørsmåla skal skrivast på NN.
+        Spørsmåla skal vere på eit nivå som passar elevar på vidaregåande skule.
+        Spørsmåla gjes som ei punktliste inne i ein <answer>-tag.`,
+    },
+  },
+  en: {
+    summary: {
+      role: `
+        You have an extensive experience from the educational sector.
+        You are specialized in summarizing educational articles to make them more accessible for a wider audience.`,
+      generalInstructions: `
+        Your task is to help improving the learning experience for the students.
+        To achieve this you have to read and summarize articles in a way that high school students can easily gain an impression of the most important points in the article.`,
+      formatInstructions: `
+        Your task is to read through and write a summary of the article within the <draft> tag.
+        The title of the article is in the <title> tag.
+        The summary must be written in EN.
+        The summary must be less than 300 words, and written inside an <answer> tag.
+        The summary should not be given as a list and should not include the title.`,
+    },
+    metaDescription: {
+      role: `
+        You have an extensive experience from the educational sector.
+        You are specialized in finding good ways to describe educational articles, in a way that the reader can understand what the article is about from a very short summary.`,
+      generalInstructions: `
+        Your task is to read through an article and write a meta description about it.`,
+      formatInstructions: `
+        The title of the article is in the <title> tag.
+        The content of the article is in the <draft> tag.
+        The meta description must be written in EN.
+        The meta description must be less than 15 words, and written inside an <answer> tag.`,
+    },
+    alternativePhrasing: {
+      role: `
+        You have an extensive experience from the educational sector.
+        You are specialized in rewriting educational articles to improve its contents, with focus on clarity and readability.`,
+      generalInstructions: `
+        Your task is to suggest a better phrasing of the text in an excerpt.`,
+      formatInstructions: `
+        The excerpt is given in the <excerpt> tag.
+        The content in the <draft> tag can be used as context, but should not be reused as part of the suggested rephrasing.
+        The suggested rephrasing is to be written in EN.
+        The suggested rephrasing is to be written inside an <answer> tag.`,
+    },
+    altText: {
+      role: `
+        You have an extensive experience from the educational sector.
+        You are specialized in writing alternative texts for images to make them accessible for all students.`,
+      generalInstructions: `
+        Your task is to write an alternative text for the attached image.`,
+      formatInstructions: `
+        The alternative text must be written in EN.
+        The description must be at most 125 characters, and written inside an <answer> tag.`,
+    },
+    reflection: {
+      role: `
+        You have extensive experience from the educational sector.
+        You are specialized in analyzing educational articles to find good reflection questions which enables the students to reflect on what they have read.`,
+      generalInstructions: `
+        Your task is to read through an article and write five reflection questions based on the article.`,
+      formatInstructions: `
+        The article is given in the <draft> tag.
+        The questions should be written in EN.
+        The questions must be on a level which fits students in high school.
+        The reflection questions must be given as bulletpoints in an <answer> tag.`,
+    },
+  },
+};
diff --git a/src/server/llmQueries.ts b/src/server/llmQueries.ts
index d10c1a9ade..4d48cc9e09 100644
--- a/src/server/llmQueries.ts
+++ b/src/server/llmQueries.ts
@@ -6,280 +6,88 @@
  *
  */
 
-const DEBUG_INSTRUCTION = {
-  nb: "Utenfor <answer>-taggen skal du levere refleksjoner på hvorfor du har valgt å inkludere de punktene du har valgt først.",
-  nn: "Utanfor <answer>-taggen skal du levere refleksjonar på kvifor du har valt å inkludere dei punkta du har valt først.",
-  en: "Outside of the <answer> tag you are to write reflections as to why you have chosen to include the selected points first.",
+import { unreachable } from "../util/guards";
+import { PromptVariables, PromptType, PromptPayload } from "../interfaces";
+import { DEBUG_INSTRUCTION, ERROR_INSTRUCTION, PROMPTS } from "./llmPrompts";
+import { isLlmLanguageCode, LlmLanguageCode, Prompt } from "./llmTypes";
+
+type LlmQuery<T extends PromptVariables> = {
+  components: (variables: T) => string;
+  promptSelector: (lang: LlmLanguageCode) => Prompt;
 };
 
-const ERROR_INSTRUCTION = {
-  nb: "Om du ikke klarer å levere svaret på det spesifiserte <answer>-formatet skal grunnen skrives i en <ERROR>-tag.",
-  nn: "Om du ikkje klarar å levere svaret på det spesifiserte <answer>-formatet skal grunnen skrivast i ein <ERROR>-tag.",
-  en: "If you cannot deliver the answer in the given format <answer>, the reasoning should be written inside an <ERROR> tag.",
-};
-
-interface SummaryVariables {
-  type: "summary";
-  text: string;
-  title: string;
-}
-
-interface AlttextVariables {
-  type: "alttext";
-  image: {
-    fileType: string;
-    base64: string;
-  };
-}
-
-interface ReflectionVariables {
-  type: "reflection";
-  text: string;
-}
-
-interface MetaDescriptionVariables {
-  type: "metaDescription";
-  text: string;
-  title: string;
-}
-
-interface AlternativePhrasingVariables {
-  type: "alternativePhrasing";
-  text: string;
-  excerpt: string;
-}
-
-export type PromptVariables =
-  | SummaryVariables
-  | AlttextVariables
-  | ReflectionVariables
-  | MetaDescriptionVariables
-  | AlternativePhrasingVariables;
-
-type LanguageCode = "nn" | "nb" | "en";
-
-type ArticleSummaryQuery = Record<LanguageCode, (text: string, title: string) => string>;
-
-const ARTICLE_SUMMARY_QUERY: ArticleSummaryQuery = {
-  nb: (text: string, title: string) => `
-        <draft> ${text} </draft>
-        <Instructions>
-          Du har lang erfaring fra utdanningssektoren.
-          Du har spesialisert deg i å oppsummere fagartikler for å gjøre dem mer tilgjengelige for et bredere publikum.
-          Du har fått som oppdrag å hjelpe med å forbedre læringsopplevelsen for elevene.
-          For å gjøre dette må du lese gjennom og lage en oppsummering av artikler sånn at videregåendestudentene lett kan få et inntrykk av de viktigste poengene i artikkelen.
-          Du har fått som oppdrag å lese gjennom og skrive en oppsummering til artikkelen i <draft>-taggen. 
-          Tittelen til artikkelen er "${title}". 
-          Oppsummeringen skal være på NB.
-          Oppsummeringen skal være på mindre enn 300 ord, og skrives i en <answer>-tag.
-          Oppsummeringen skal ikke være en liste og skal ikke inkludere tittelen.
-          ${DEBUG_INSTRUCTION.nb}
-          ${ERROR_INSTRUCTION.nb}
-        </Instructions>`,
-  en: (text: string, title: string) => `
-        <draft>${text}</draft>
-        <Instructions>
-          You have an extensive experience from the educational sector.
-          You are specialized in summarizing educational articles to make them more accessible for a wider audience.
-          Your task is to help improving the learning experience for the students.
-          To achieve this you have to read and summarize articles in a way that high school students can easily gain an impression of the most important points in the article.
-          Your task is to read through and write a summary of the article within the <draft> tag.
-          The title of the article is "${title}". 
-          The summary must be written in EN.
-          The summary must be less than 300 words, and written inside an <answer> tag.
-          The summary should not be given as a list and should not include the title.
-          ${DEBUG_INSTRUCTION.en}
-          ${ERROR_INSTRUCTION.en}
-        </Instructions>`,
-  nn: (text: string, title: string) => `
-          <draft>${text}</draft>
-          <Instructions>
-          Du har lang erfaring frå utdanningssektoren.
-          Du har spesialisert deg i å oppsummere fagartiklar for å gjere dei meir tilgjengeleg for eit større publikum.
-          Du har fått som oppdrag å hjelpe med å forbetre læringsopplevelsen for elevane.
-          For å gjere dette må du lese gjennom og lage ei oppsummering av artiklar så vidaregåandestudentane lett kan få eit inntrykk av dei viktigaste poenga i artikkelen.
-          Du har fått i oppgåve å lese gjennom og skrive ei oppsummering til artikkelen i <draft>-taggen.
-          Tittelen til artikkelen er "${title}". 
-          Oppsummeringa skal vere på NN.
-          Oppsummeringa skal vere på mindre enn 300 ord, og skrives i ein <answer>-tag.
-          Oppsummeringa skal ikkje vere ein liste og skal ikkje inkludera tittelen.
-          ${DEBUG_INSTRUCTION.nn}
-          ${ERROR_INSTRUCTION.nn}
-        </Instructions>`,
-};
+type ExtractVariables<T extends PromptType> = Extract<PromptVariables, { type: T }>;
 
-type MetaDescriptionQuery = Record<LanguageCode, (text: string, title: string) => string>;
+const createQuery = <T extends PromptType>(
+  type: T,
+  componentSelectors: Record<string, keyof ExtractVariables<T>> = {},
+): LlmQuery<ExtractVariables<T>> => {
+  const components = (variables: ExtractVariables<T>) =>
+    Object.entries(componentSelectors)
+      .map(([tag, variableKey]) => `<${tag}>${variables[variableKey]}</${tag}>`)
+      .join("\n");
 
-const META_DESCRIPTION_QUERY: MetaDescriptionQuery = {
-  nb: (text: string, title: string) => `
-        <draft>${text}</draft>
-        <Instructions>
-          Du har lang erfaring fra utdanningssektoren.
-          Du er spesialist i å finne gode måter å beskrive fagartikler på, slik at man fra en kort oppsummering klarer å forstå hva artiklene handler om.
-          Du har fått som oppdrag å lese gjennom artikkelen i <draft>-taggen og skrive en metabeskrivelse om den.
-          Tittelen på artikkelen er "${title}".
-          Metabeskrivelsen skal være skrevet på NB.
-          Metabeskrivelsen skal være på mindre enn 15 ord, og skal skrives i en <answer>-tag.
-          ${DEBUG_INSTRUCTION.nb}
-          ${ERROR_INSTRUCTION.nb}
-         </Instructions>`,
-  en: (text: string, title: string) => `
-        <draft>${text}</draft>
-        <Instructions>
-          You have an extensive experience from the educational sector.
-          You are specialized in finding good ways to describe educational articles, in a way that the reader can understand what the article is about from a very short summary.
-          Your task is to read through the article within the <draft> tag, and write a meta description about it.
-          The title of the article is "${title}".
-          The meta description must be written in EN.
-          The meta description must be less than 15 words, and written inside an <answer> tag.
-          ${DEBUG_INSTRUCTION.en}
-          ${ERROR_INSTRUCTION.en}
-         </Instructions>`,
-  nn: (text: string, title: string) => `
-        <draft> ${text} </draft>
-        <Instructions>
-          Du har lang erfaring frå utdanningssektoren.
-          Du er spesialist i å finne gode måter å skildre fagartiklar på, slik at ein frå ei kort oppsummering kan forstå kva artikkelen handlar om.
-          Du har fått i oppgåve å lese gjennom artikkelen i <draft>-taggen og skrive ein metabeskrivelse om den.
-          Tittelen på artikkelen er "${title}".
-          Metabeskrivinga skal vere skrevet på NN.
-          Metabeskrivinga skal vere på mindre enn 15 ord, og skrives i ein <answer>-tag.
-          ${DEBUG_INSTRUCTION.nn}
-          ${ERROR_INSTRUCTION.nn}
-         </Instructions>`,
-};
-
-type AlternativePhrasingQuery = Record<LanguageCode, (text: string, excerpt: string) => string>;
+  const promptSelector = (lang: LlmLanguageCode) => PROMPTS[lang][type];
 
-const ALTERNATIVE_PRHASING_QUERY: AlternativePhrasingQuery = {
-  nb: (text: string, excerpt: string) => `
-        <excerpt>${excerpt}</excerpt>
-        <draft>${text}</draft>
-        <Instructions>
-          Du har lang erfaring fra utdanningssektoren.
-          Du er spesialist i å renskrive fagartikler for å forbedre det tekstlige innholdet, med fokus på tydelighet og lesbarhet.
-          Du har fått som oppdrag å foreslå en bedre formulering av teksten i <excerpt>-taggen.
-          Innholdet i <draft>-taggen kan brukes som kontekst, men skal ikke gjenbrukes som del av den foreslåtte omformuleringen.
-          Forslaget til en forbedret tekst skal være skrevet på NB.
-          Forslaget til en forbedret tekst skal være skrevet i en <answer>-tag.
-          ${DEBUG_INSTRUCTION.nb}
-          ${ERROR_INSTRUCTION.nb}
-        </Instructions>`,
-  en: (text: string, excerpt: string) => `
-        <excerpt>${excerpt}</excerpt>
-        <draft>${text}</draft>
-        <Instructions>
-          You have an extensive experience from the educational sector.
-          You are specialized in rewriting educational articles to improve its contents, with focus on clarity and readability.
-          Your task is to suggest a better phrasing of the text in the <excerpt> tag.
-          The content in the <draft> tag can be used as context, but should not be reused as part of the suggested rephrasing.
-          The suggested rephrasing is to be written in EN.
-          The suggested rephrasing is to be written inside an <answer> tag.
-          ${DEBUG_INSTRUCTION.en}
-          ${ERROR_INSTRUCTION.en}         
-        </Instructions>`,
-  nn: (text: string, excerpt: string) => `
-        <excerpt>${excerpt}</excerpt>
-        <draft>${text}</draft>
-        <Instructions>
-          Du har lang erfaring frå utdanningssektoren.
-          Du er spesialist i å reinskrive fagartiklar for å gjere det tekstlige innhaldet betre, med fokus på klarleik og lesbarheit.
-          Du har fått i oppgåve å foreslå ei betre formulering av teksten i <excerpt>-taggen.
-          Innhaldet i <draft>-taggen kan nyttast som kontekst, men skal ikkje gjenbrukast som del av den føreslåtte omformuleringa.
-          Forslaget til ein betre formulering skal vere skrive på NN.
-          Forslaget til ein betre formulering skal vere skrive i ein <answer>-tag.
-          ${DEBUG_INSTRUCTION.nn}
-          ${ERROR_INSTRUCTION.nn}
-        </Instructions>`,
+  return {
+    promptSelector,
+    components,
+  };
 };
 
-type AlttextQuery = Record<LanguageCode, string>;
+const ARTICLE_SUMMARY_QUERY = createQuery("summary", {
+  draft: "text",
+  title: "title",
+});
 
-const ALT_TEXT_QUERY: AlttextQuery = {
-  nb: `<Instructions>
-          Du har lang erfaring fra utdanningssektoren. 
-          Du er spesialist i å skrive alternative tekster for bilder for å gjøre dem tilgjengelige for alle elever.
-          Du har fått som oppdrag å skrive en alternativ tekst for det vedlagte bildet. 
-          Teksten skal være på NB.
-          Beskrivelsen skal være på maks 125 tegn, og skrevet i en <answer>-tag.
-          ${DEBUG_INSTRUCTION.nb}
-          ${ERROR_INSTRUCTION.nb}
-        </Instructions>`,
-  en: `<Instructions>
-          You have an extensive experience from the educational sector.
-          You are specialized in writing alternative texts for images to make them accessible for all students.
-          Your task is to write an alternative text for the image in EN.
-          The description must be at most 125 characters, and written inside an <answer> tag.
-          ${DEBUG_INSTRUCTION.en}
-          ${ERROR_INSTRUCTION.en}
-        </Instructions>`,
-  nn: `<Instructions>
-          Du har lang erfaring frå utdanningssektoren.
-          Du er spesialist i å skrive alternative tekstar for bilete for å gjere dei tilgjengelege for alle elevar.
-          Du har fått som oppgåve å skrive ein alternativ tekst for det vedlagte biletet.
-          Teksta skal vere på NN.
-          Beskrivinga skal vere på maks 125 teikn, og skrive i ein <answer>-tag.
-          ${DEBUG_INSTRUCTION.nn}
-          ${ERROR_INSTRUCTION.nn}
-        </Instructions>`,
-};
+const META_DESCRIPTION_QUERY = createQuery("metaDescription", {
+  draft: "text",
+});
 
-type ReflectionQuery = Record<LanguageCode, (text: string) => string>;
+const ALTERNATIVE_PRHASING_QUERY = createQuery("alternativePhrasing", {
+  excerpt: "excerpt",
+  draft: "text",
+});
 
-const REFLECTION_QUESTION_QUERY: ReflectionQuery = {
-  nb: (text: string) => `
-        <draft>${text}</draft>
-        <Instructions>
-          Du har lang erfaring fra utdanningssektoren. 
-          Du er spesialist i å analysere fagartikler for å finne gode refleksjonsspørsmål som får elevene til å tenke gjennom hva de har lest.
-          Du har fått som oppdrag å lese gjennom artikkelen i <draft>-taggen og skrive fem refleksjonsspørsmål basert på artikkelen. 
-          Spørsmålene skal skrives på NB. 
-          Spørsmålene skal være på et nivå som passer for elever på videregående skole.
-          Spørsmålene gis som en punktliste inne i en <answer>-tag
-          ${DEBUG_INSTRUCTION.nb}
-          ${ERROR_INSTRUCTION.nb}
-        </Instructions>`,
-  en: (text: string) => `
-        <draft>${text}</draft>
-        <Instructions>
-          You have extensive experience from the educational sector. 
-          You are specialized in analyzing educational articles to find good reflection questions which enables the students to reflect on what they have read.
-          Your task is to read through the article in the <draft> tag and write five reflection questions based on the article.
-          The questions should be written in EN.
-          The questions must be on a level which fits students in high school.
-          The reflection questions must be given as bulletpoints in an <answer> tag.
-          ${DEBUG_INSTRUCTION.en}
-          ${ERROR_INSTRUCTION.en}
-        </Instructions>`,
-  nn: (text: string) => `
-        <draft>${text}</draft>
-        <Instructions>
-          Du har lang erfaring frå utdanningssektoren. 
-          Du er spesialist i å analysere fagartiklar for å finne gode refleksjonsspørsmål som får elevane til å tenkje gjennom kva dei har lest.
-          Du har fått i oppgåve å lese gjennom artikkelen i <draft>-taggen og skrive fem refleksjonsspørsmål basert på artikkelen. 
-          Spørsmåla skal skrivast på NN.
-          Spørsmåla skal vere på eit nivå som passar elevar på vidaregåande skule.
-          Spørsmåla gjes som ei punktliste inne i ein <answer>-tag.
-          ${DEBUG_INSTRUCTION.nn}
-          ${ERROR_INSTRUCTION.nn}
-        </Instructions>`,
-};
+const ALT_TEXT_QUERY = createQuery("altText");
 
-const isSupportedLanguage = (lang: string): lang is LanguageCode => lang === "nb" || lang === "nn" || lang === "en";
+const REFLECTION_QUESTION_QUERY = createQuery("reflection", {
+  draft: "text",
+});
 
-export const getPromptQuery = (payload: PromptVariables, languageCode: string) => {
-  const language = isSupportedLanguage(languageCode) ? languageCode : "nb";
-  switch (payload.type) {
-    case "alternativePhrasing":
-      return ALTERNATIVE_PRHASING_QUERY[language](payload.text, payload.excerpt);
-    case "metaDescription":
-      return META_DESCRIPTION_QUERY[language](payload.text, payload.title);
+const getLlmQuery = <T extends PromptVariables>(type: T["type"]): LlmQuery<T> => {
+  switch (type) {
     case "summary":
-      return ARTICLE_SUMMARY_QUERY[language](payload.text, payload.title);
+      return ARTICLE_SUMMARY_QUERY as LlmQuery<T>;
+    case "metaDescription":
+      return META_DESCRIPTION_QUERY as LlmQuery<T>;
+    case "alternativePhrasing":
+      return ALTERNATIVE_PRHASING_QUERY as LlmQuery<T>;
+    case "altText":
+      return ALT_TEXT_QUERY as LlmQuery<T>;
     case "reflection":
-      return REFLECTION_QUESTION_QUERY[language](payload.text);
-    case "alttext":
+      return REFLECTION_QUESTION_QUERY as LlmQuery<T>;
     default:
-      return ALT_TEXT_QUERY[language];
+      return unreachable(type);
   }
 };
+
+export const llmQueryText = (payload: PromptPayload<PromptVariables>, language: string) => {
+  const lang = isLlmLanguageCode(language) ? language : "nb";
+  const { components, promptSelector } = getLlmQuery(payload.type);
+  const prompt = promptSelector(lang);
+  const role = payload.role ?? prompt.role;
+  const generalInstructions = payload.instructions ?? prompt.generalInstructions;
+
+  return {
+    role,
+    message: `
+      ${components(payload)}
+      <instructions>
+      ${generalInstructions}
+      ${prompt.formatInstructions}
+      ${DEBUG_INSTRUCTION[lang]}
+      ${ERROR_INSTRUCTION[lang]}
+      </instructions>`,
+  };
+};
diff --git a/src/server/llmTypes.ts b/src/server/llmTypes.ts
new file mode 100644
index 0000000000..4110411436
--- /dev/null
+++ b/src/server/llmTypes.ts
@@ -0,0 +1,18 @@
+/**
+ * Copyright (c) 2025-present, NDLA.
+ *
+ * This source code is licensed under the GPLv3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export type Prompt = {
+  role: string;
+  generalInstructions: string;
+  formatInstructions: string;
+};
+
+export type LlmLanguageCode = "nb" | "nn" | "en";
+
+export const isLlmLanguageCode = (lang: string): lang is LlmLanguageCode =>
+  lang === "nb" || lang === "nn" || lang === "en";
diff --git a/src/server/utils.ts b/src/server/utils.ts
index 271c7efe60..4575a806ca 100644
--- a/src/server/utils.ts
+++ b/src/server/utils.ts
@@ -6,15 +6,15 @@
  *
  */
 
-const validTypesAi = ["summary", "metaDescription", "alttext", "alternativePhrasing", "reflection"];
+import { isPromptType } from "../interfaces";
 
 export const isValidRequestBody = (body: any) => {
   const type = body?.type;
-  if (!type || !validTypesAi.includes(type)) {
+  if (!isPromptType(type)) {
     return false;
   } else if ((type === "summary" || type === "metaDescription") && !body.title && !body.text) {
     return false;
-  } else if (type === "alttext" && !body.image?.fileType && !body.image?.base64) {
+  } else if (type === "altText" && !body.image?.fileType && !body.image?.base64) {
     return false;
   } else if (type === "alternativePhrasing" && !body.text && !body.excerpt) {
     return false;


Reviews:
user: login: Jonas-C
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T08:30:31Z
user: login: gunnarvelle
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T10:24:54Z
user: login: amatho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T12:13:50Z
user: login: gunnarvelle
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T12:19:20Z
user: login: amatho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T07:56:48Z
user: login: Jonas-C
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T09:34:29Z
user: login: Jonas-C
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T10:54:06Z
user: login: Jonas-C
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T07:14:13Z
user: login: Jonas-C
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T08:04:53Z
user: login: amatho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:54:30Z
user: login: amatho
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:55:18Z
user: login: Jonas-C
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T14:23:37Z

Review comments:
path: src/server/llmPrompts.ts
user: login: Jonas-C
body: @gunnarvelle Gidder du se over nynorsk?
created_at: 2025-05-16T08:30:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2283,28 +2283,30 @@ const phrases = {
   textGeneration: {
     error: "Noe gikk galt under generering av tekst. Melding fra tjenesten: {{message}}",
     errorImage: "Noe gikk galt, kunne ikke prosessere bildet.",
-    summary: "Oppsummering",
+    insert: "Sett inn",
     replace: "Erstatt",
-    add: "Legg til etter",
+    append: "Legg til etter",
     chosenText: "Valgt tekst",
-    suggestedText: "Forslag til ny formulering",
-    alternativeText: "Alternativ formulering",
-    generate: {
-      metaDescription: "Generer metabeskrivelse",
-      reflection: "Generer refleksjonsspørsmål",
-      summary: "Generer oppsummering",
-      variant: "Generer forslag",
-      alttext: "Generer alttekst",
-      transcription: "Generer transkribering",
-    },
-    failed: {
-      alttext: "Her gikk det galt! Klarte ikke å generere alttext. \n {{ error }}",
-      reflection: "Her gikk det galt! Klarte ikke å generere refleksjonsspørsmål. \n {{ error }}",
-      summary: "Her gikk det galt! Klarte ikke å generere oppsummering. \n {{ error }}",
-      transcription: "Her gikk det galt! Klarte ikke å generere transkribering.",
-      metaDescription: "Her gikk det galt! Klarte ikke å generere metabeskrivelse. \n {{ error }}",
-      variant: "Her gikk det galt! Klarte ikke å generere forslag. \n {{ error }}",
-    },
+    suggestedText: "Forslag til $t(textGeneration.types.{{type}})",
+    dialogTitle: "Generer $t(textGeneration.types.{{type}})",
+    generateButton: "Generer $t(textGeneration.types.{{type}})",
path: src/phrases/phrases-nb.ts
user: login: gunnarvelle
body: Er det sårbart å ha referanse til t-funksjonen her? Antar det funker siden du skriver det slik, men eg hadde tenkt at det naturlige her hadde vore `suggestedText: "Forslag til {{type}}",` og med setting av type der strengen brukes. Men det er vel berre semantikk.
created_at: 2025-05-16T10:22:23Z
author_association: MEMBER
path: src/server/llmPrompts.ts
user: login: gunnarvelle
body: Er ikkje desse berre henta fra llmQueries.ts?
created_at: 2025-05-16T10:24:34Z
author_association: MEMBER
diff_hunk: @@ -2283,28 +2283,30 @@ const phrases = {
   textGeneration: {
     error: "Noe gikk galt under generering av tekst. Melding fra tjenesten: {{message}}",
     errorImage: "Noe gikk galt, kunne ikke prosessere bildet.",
-    summary: "Oppsummering",
+    insert: "Sett inn",
     replace: "Erstatt",
-    add: "Legg til etter",
+    append: "Legg til etter",
     chosenText: "Valgt tekst",
-    suggestedText: "Forslag til ny formulering",
-    alternativeText: "Alternativ formulering",
-    generate: {
-      metaDescription: "Generer metabeskrivelse",
-      reflection: "Generer refleksjonsspørsmål",
-      summary: "Generer oppsummering",
-      variant: "Generer forslag",
-      alttext: "Generer alttekst",
-      transcription: "Generer transkribering",
-    },
-    failed: {
-      alttext: "Her gikk det galt! Klarte ikke å generere alttext. \n {{ error }}",
-      reflection: "Her gikk det galt! Klarte ikke å generere refleksjonsspørsmål. \n {{ error }}",
-      summary: "Her gikk det galt! Klarte ikke å generere oppsummering. \n {{ error }}",
-      transcription: "Her gikk det galt! Klarte ikke å generere transkribering.",
-      metaDescription: "Her gikk det galt! Klarte ikke å generere metabeskrivelse. \n {{ error }}",
-      variant: "Her gikk det galt! Klarte ikke å generere forslag. \n {{ error }}",
-    },
+    suggestedText: "Forslag til $t(textGeneration.types.{{type}})",
+    dialogTitle: "Generer $t(textGeneration.types.{{type}})",
+    generateButton: "Generer $t(textGeneration.types.{{type}})",
path: src/phrases/phrases-nb.ts
user: login: amatho
body: Ja, begge deler funker fint. Vi bruker `$t(...)` et par andre steder i phrases-fila allerede, men kan godt bytte om hvis du vil
created_at: 2025-05-16T12:13:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2283,28 +2283,30 @@ const phrases = {
   textGeneration: {
     error: "Noe gikk galt under generering av tekst. Melding fra tjenesten: {{message}}",
     errorImage: "Noe gikk galt, kunne ikke prosessere bildet.",
-    summary: "Oppsummering",
+    insert: "Sett inn",
     replace: "Erstatt",
-    add: "Legg til etter",
+    append: "Legg til etter",
     chosenText: "Valgt tekst",
-    suggestedText: "Forslag til ny formulering",
-    alternativeText: "Alternativ formulering",
-    generate: {
-      metaDescription: "Generer metabeskrivelse",
-      reflection: "Generer refleksjonsspørsmål",
-      summary: "Generer oppsummering",
-      variant: "Generer forslag",
-      alttext: "Generer alttekst",
-      transcription: "Generer transkribering",
-    },
-    failed: {
-      alttext: "Her gikk det galt! Klarte ikke å generere alttext. \n {{ error }}",
-      reflection: "Her gikk det galt! Klarte ikke å generere refleksjonsspørsmål. \n {{ error }}",
-      summary: "Her gikk det galt! Klarte ikke å generere oppsummering. \n {{ error }}",
-      transcription: "Her gikk det galt! Klarte ikke å generere transkribering.",
-      metaDescription: "Her gikk det galt! Klarte ikke å generere metabeskrivelse. \n {{ error }}",
-      variant: "Her gikk det galt! Klarte ikke å generere forslag. \n {{ error }}",
-    },
+    suggestedText: "Forslag til $t(textGeneration.types.{{type}})",
+    dialogTitle: "Generer $t(textGeneration.types.{{type}})",
+    generateButton: "Generer $t(textGeneration.types.{{type}})",
path: src/phrases/phrases-nb.ts
user: login: gunnarvelle
body: Er ikkje så farlig. Tenkte kanskje det var meir framtidsretta å ikkje avhenge av i18n-implementasjonsdetaljer.
created_at: 2025-05-16T12:19:20Z
author_association: MEMBER
path: src/server/llmPrompts.ts
user: login: amatho
body: Jo stemmer det, men har måttet skrive de om littegrann
created_at: 2025-05-19T07:56:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,45 +6,62 @@
  *
  */
 
-export type Payload =
+export type PromptType = PromptVariables["type"];
+
+export type PromptVariables =
   | SummaryVariables
   | AltTextVariables
   | AlternativePhrasingVariables
   | MetaDescriptionVariables
   | ReflectionVariables;
 
-interface BaseVariables {
-  language: string;
-  max_tokens?: number;
+export interface SummaryVariables {
+  type: "summary";
+  text: string;
+  title: string;
 }
 
-export interface AlternativePhrasingVariables extends BaseVariables {
-  type: "alternativePhrasing";
-  text: string;
-  excerpt: string;
+export interface AltTextVariables {
+  type: "altText";
+  image: {
+    fileType: string;
+    base64: string;
+  };
 }
 
-export interface ReflectionVariables extends BaseVariables {
-  type: "reflection";
+export interface AlternativePhrasingVariables {
+  type: "alternativePhrasing";
   text: string;
+  excerpt: string;
 }
 
-export interface MetaDescriptionVariables extends BaseVariables {
+export interface MetaDescriptionVariables {
   type: "metaDescription";
   text: string;
   title: string;
 }
 
-export interface AltTextVariables extends BaseVariables {
-  type: "alttext";
-  image: {
-    fileType: string;
-    base64: string;
-  };
+export interface ReflectionVariables {
+  type: "reflection";
+  text: string;
 }
 
-export interface SummaryVariables extends BaseVariables {
-  type: "summary";
-  text: string;
-  title: string;
+const promptTypes: PromptType[] = ["summary", "altText", "alternativePhrasing", "metaDescription", "reflection"];
+
+export const isPromptType = (type: string): type is PromptType => promptTypes.includes(type as PromptType);
+
+export type Payload<T extends PromptVariables> = T & {
+  language: string;
+  role?: string;
+  instructions?: string;
+  max_tokens?: number;
+};
+
+export type LanguageCode = "nb" | "nn" | "en";
+
+export const isLanguageCode = (lang: string): lang is LanguageCode => lang === "nb" || lang === "nn" || lang === "en";
path: src/modules/llm/llmApiTypes.ts
user: login: Jonas-C
body: Modules inneholder vel egentlig bare fetching-logikk. Hører dette mer hjemme et annet sted?
created_at: 2025-05-19T09:23:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,236 @@
+/**
+ * Copyright (c) 2025-present, NDLA.
+ *
+ * This source code is licensed under the GPLv3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Portal } from "@ark-ui/react";
+import { FileListLine } from "@ndla/icons";
+import {
+  Button,
+  DialogBackdrop,
+  DialogBody,
+  DialogCloseTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogRoot,
+  DialogRootProps,
+  DialogTitle,
+  FieldHelper,
+  FieldLabel,
+  FieldRoot,
+  FieldTextArea,
+  Heading,
+  Input,
+  Label,
+  Skeleton,
+  SwitchControl,
+  SwitchHiddenInput,
+  SwitchLabel,
+  SwitchRoot,
+  SwitchThumb,
+  Text,
+} from "@ndla/primitives";
+import { HStack, Stack, styled } from "@ndla/styled-system/jsx";
+import { DialogCloseButton } from "./DialogCloseButton";
+import { FormActionsContainer } from "./FormikForm";
+import { PromptVariables } from "../modules/llm/llmApiTypes";
+import { useGenerateAIMutation } from "../modules/llm/llmMutations";
+import { useDefaultAiPrompts } from "../modules/llm/llmQueries";
+import { NdlaErrorPayload } from "../util/resolveJsonOrRejectWithError";
+
+const CustomPromptsContainer = styled(Stack, {
+  base: {
+    marginBlockEnd: "small",
+  },
+});
+
+const StyledText = styled(Text, {
+  base: {
+    border: "1px solid",
+    borderColor: "stroke.default",
+    borderRadius: "small",
+    padding: "small",
+    whiteSpace: "pre-wrap",
+  },
+});
+
+const trimIndent = (text: string) =>
+  text
+    .split("\n")
+    .map((t) => t.trim())
+    .filter(Boolean)
+    .join(" ");
+
+type BaseProps = {
+  promptVariables: PromptVariables | (() => PromptVariables);
+  language: string;
+  maxTokens?: number;
+  onInsert?: (generatedText: string) => void;
+  onAppend?: (generatedText: string) => void;
+  onReplace?: (generatedText: string) => void;
+};
+
+const PromptDialogContent = ({
+  promptVariables: promptVariablesProp,
+  language,
+  maxTokens,
+  onInsert,
+  onAppend,
+  onReplace,
+}: BaseProps) => {
+  const { t } = useTranslation();
+  const [customPromptChecked, setCustomPromptChecked] = useState(false);
+  const [rolePrompt, setRolePrompt] = useState("");
+  const [instructionsPrompt, setInstructionsPrompt] = useState("");
+  const [generatedText, setGeneratedText] = useState("");
+  const [error, setError] = useState<string | undefined>(undefined);
+  const { mutateAsync, isPending: mutateIsPending } = useGenerateAIMutation<PromptVariables>();
+  const promptVariables = typeof promptVariablesProp === "function" ? promptVariablesProp() : promptVariablesProp;
+  const { data: defaultPrompts, isPending: defaultPromptsIsPending } = useDefaultAiPrompts(
path: src/components/AiPromptDialog.tsx
user: login: Jonas-C
body: Jeg synes ofte det er nice å ikke destrukturere disse, så man slipper å aliase variablene. Enklere å forstå hvor `defaultAiPromptsQuery.isPending` kommer fra.
created_at: 2025-05-19T09:29:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,236 @@
+/**
+ * Copyright (c) 2025-present, NDLA.
+ *
+ * This source code is licensed under the GPLv3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Portal } from "@ark-ui/react";
+import { FileListLine } from "@ndla/icons";
+import {
+  Button,
+  DialogBackdrop,
+  DialogBody,
+  DialogCloseTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogRoot,
+  DialogRootProps,
+  DialogTitle,
+  FieldHelper,
+  FieldLabel,
+  FieldRoot,
+  FieldTextArea,
+  Heading,
+  Input,
+  Label,
+  Skeleton,
+  SwitchControl,
+  SwitchHiddenInput,
+  SwitchLabel,
+  SwitchRoot,
+  SwitchThumb,
+  Text,
+} from "@ndla/primitives";
+import { HStack, Stack, styled } from "@ndla/styled-system/jsx";
+import { DialogCloseButton } from "./DialogCloseButton";
+import { FormActionsContainer } from "./FormikForm";
+import { PromptVariables } from "../modules/llm/llmApiTypes";
+import { useGenerateAIMutation } from "../modules/llm/llmMutations";
+import { useDefaultAiPrompts } from "../modules/llm/llmQueries";
+import { NdlaErrorPayload } from "../util/resolveJsonOrRejectWithError";
+
+const CustomPromptsContainer = styled(Stack, {
+  base: {
+    marginBlockEnd: "small",
+  },
+});
+
+const StyledText = styled(Text, {
+  base: {
+    border: "1px solid",
+    borderColor: "stroke.default",
+    borderRadius: "small",
+    padding: "small",
+    whiteSpace: "pre-wrap",
+  },
+});
+
+const trimIndent = (text: string) =>
+  text
+    .split("\n")
+    .map((t) => t.trim())
+    .filter(Boolean)
+    .join(" ");
+
+type BaseProps = {
+  promptVariables: PromptVariables | (() => PromptVariables);
+  language: string;
+  maxTokens?: number;
+  onInsert?: (generatedText: string) => void;
+  onAppend?: (generatedText: string) => void;
+  onReplace?: (generatedText: string) => void;
+};
+
+const PromptDialogContent = ({
+  promptVariables: promptVariablesProp,
+  language,
+  maxTokens,
+  onInsert,
+  onAppend,
+  onReplace,
+}: BaseProps) => {
+  const { t } = useTranslation();
+  const [customPromptChecked, setCustomPromptChecked] = useState(false);
+  const [rolePrompt, setRolePrompt] = useState("");
+  const [instructionsPrompt, setInstructionsPrompt] = useState("");
+  const [generatedText, setGeneratedText] = useState("");
+  const [error, setError] = useState<string | undefined>(undefined);
+  const { mutateAsync, isPending: mutateIsPending } = useGenerateAIMutation<PromptVariables>();
+  const promptVariables = typeof promptVariablesProp === "function" ? promptVariablesProp() : promptVariablesProp;
+  const { data: defaultPrompts, isPending: defaultPromptsIsPending } = useDefaultAiPrompts(
+    promptVariables.type,
+    language,
+  );
+
+  useEffect(() => {
+    if (defaultPrompts) {
+      if (rolePrompt === "") setRolePrompt(trimIndent(defaultPrompts.role));
+      if (instructionsPrompt === "") setInstructionsPrompt(trimIndent(defaultPrompts.instructions));
+    }
+  }, [defaultPrompts, rolePrompt, instructionsPrompt]);
+
+  const fetchAiGeneratedText = async () => {
+    setError(undefined);
+    mutateAsync({
+      ...promptVariables,
+      language,
+      max_tokens: maxTokens,
+      role: rolePrompt.trim() ? rolePrompt : undefined,
+      instructions: instructionsPrompt.trim() ? instructionsPrompt : undefined,
+    })
+      .then((res) => setGeneratedText(res))
+      .catch((err: NdlaErrorPayload) =>
+        setError(t(`textGeneration.failed`, { type: promptVariables.type, error: err.messages })),
+      );
+  };
+
+  return (
+    <DialogContent>
+      <DialogHeader>
+        <DialogTitle>{t("textGeneration.dialogTitle", { type: promptVariables.type })}</DialogTitle>
+        <DialogCloseButton />
+      </DialogHeader>
+      <DialogBody>
+        <CustomPromptsContainer>
+          <FieldRoot>
+            <SwitchRoot
+              checked={customPromptChecked}
+              onCheckedChange={(details) => setCustomPromptChecked(details.checked)}
+            >
+              <SwitchLabel>{t("textGeneration.customPrompts.switchLabel")}</SwitchLabel>
+              <SwitchControl>
+                <SwitchThumb />
+              </SwitchControl>
+              <SwitchHiddenInput />
+            </SwitchRoot>
+          </FieldRoot>
+          {customPromptChecked ? (
+            defaultPromptsIsPending ? (
+              <Skeleton>
+                <Label>Label</Label>
+                <Input />
+              </Skeleton>
path: src/components/AiPromptDialog.tsx
user: login: Jonas-C
body: Tilgjengelighetsmessig er vel ikke dette helt topp, kanskje?
created_at: 2025-05-19T09:31:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,228 @@
+/**
+ * Copyright (c) 2025-present, NDLA.
+ *
+ * This source code is licensed under the GPLv3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Portal } from "@ark-ui/react";
+import { FileListLine } from "@ndla/icons";
+import {
+  Button,
+  DialogBackdrop,
+  DialogBody,
+  DialogCloseTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogRoot,
+  DialogRootProps,
+  DialogTitle,
+  FieldHelper,
+  FieldLabel,
+  FieldRoot,
+  FieldTextArea,
+  Heading,
+  Spinner,
+  SwitchControl,
+  SwitchHiddenInput,
+  SwitchLabel,
+  SwitchRoot,
+  SwitchThumb,
+  Text,
+} from "@ndla/primitives";
+import { HStack, Stack, styled } from "@ndla/styled-system/jsx";
+import { DialogCloseButton } from "./DialogCloseButton";
+import { FormActionsContainer } from "./FormikForm";
+import { useGenerateAIMutation } from "../modules/llm/llmMutations";
+import { useDefaultAiPrompts } from "../modules/llm/llmQueries";
+import { PromptVariables } from "../server/llmApiTypes";
path: src/components/AiPromptDialog.tsx
user: login: Jonas-C
body: Tror ikke det er så god idé å importere server-kode på klient.
created_at: 2025-05-21T10:54:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,6 +187,21 @@ const aiMiddleware = (req: Request, res: express.Response, next: express.NextFun
   }
 };
 
+router.get("/default-ai-prompts", jwtMiddleware, aiMiddleware, async (req, res) => {
path: src/server/api.ts
user: login: Jonas-C
body: Burde vi sjekke at vi støtter språkkoden? 
created_at: 2025-05-22T07:14:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,228 @@
+/**
+ * Copyright (c) 2025-present, NDLA.
+ *
+ * This source code is licensed under the GPLv3 license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Portal } from "@ark-ui/react";
+import { FileListLine } from "@ndla/icons";
+import {
+  Button,
+  DialogBackdrop,
+  DialogBody,
+  DialogCloseTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogRoot,
+  DialogRootProps,
+  DialogTitle,
+  FieldHelper,
+  FieldLabel,
+  FieldRoot,
+  FieldTextArea,
+  Heading,
+  Spinner,
+  SwitchControl,
+  SwitchHiddenInput,
+  SwitchLabel,
+  SwitchRoot,
+  SwitchThumb,
+  Text,
+} from "@ndla/primitives";
+import { HStack, Stack, styled } from "@ndla/styled-system/jsx";
+import { DialogCloseButton } from "./DialogCloseButton";
+import { FormActionsContainer } from "./FormikForm";
+import { PromptVariables } from "../interfaces";
+import { useGenerateAIMutation } from "../modules/llm/llmMutations";
+import { useDefaultAiPrompts } from "../modules/llm/llmQueries";
+import { NdlaErrorPayload } from "../util/resolveJsonOrRejectWithError";
+
+const CustomPromptsContainer = styled(Stack, {
+  base: {
+    marginBlockEnd: "small",
+  },
+});
+
+const StyledText = styled(Text, {
+  base: {
+    border: "1px solid",
+    borderColor: "stroke.default",
+    borderRadius: "small",
+    padding: "small",
+    whiteSpace: "pre-wrap",
+  },
+});
+
+const trimIndent = (text: string) =>
+  text
+    .split("\n")
+    .map((t) => t.trim())
+    .filter(Boolean)
+    .join(" ");
+
+type BaseProps = {
+  promptVariables: PromptVariables | (() => PromptVariables);
+  language: string;
+  maxTokens?: number;
+  onInsert?: (generatedText: string) => void;
+  onAppend?: (generatedText: string) => void;
+  onReplace?: (generatedText: string) => void;
+};
+
+const PromptDialogContent = ({
+  promptVariables: promptVariablesProp,
+  language,
+  maxTokens,
+  onInsert,
+  onAppend,
+  onReplace,
+}: BaseProps) => {
+  const { t } = useTranslation();
+  const [customPromptChecked, setCustomPromptChecked] = useState(false);
+  const [rolePrompt, setRolePrompt] = useState("");
+  const [instructionsPrompt, setInstructionsPrompt] = useState("");
+  const [generatedText, setGeneratedText] = useState("");
+  const [error, setError] = useState<string | undefined>(undefined);
+  const { mutateAsync, isPending: mutateIsPending } = useGenerateAIMutation<PromptVariables>();
+  const promptVariables = typeof promptVariablesProp === "function" ? promptVariablesProp() : promptVariablesProp;
+  const defaultPromptsQuery = useDefaultAiPrompts(promptVariables.type, language);
+
+  useEffect(() => {
+    if (defaultPromptsQuery.data) {
+      if (rolePrompt === "") setRolePrompt(trimIndent(defaultPromptsQuery.data.role));
+      if (instructionsPrompt === "") setInstructionsPrompt(trimIndent(defaultPromptsQuery.data.instructions));
+    }
+  }, [defaultPromptsQuery.data, rolePrompt, instructionsPrompt]);
+
+  const fetchAiGeneratedText = async () => {
+    setError(undefined);
+    mutateAsync({
+      ...promptVariables,
+      language,
+      max_tokens: maxTokens,
+      role: rolePrompt.trim() ? rolePrompt : undefined,
+      instructions: instructionsPrompt.trim() ? instructionsPrompt : undefined,
+    })
+      .then((res) => setGeneratedText(res))
+      .catch((err: NdlaErrorPayload) =>
+        setError(t(`textGeneration.failed`, { type: promptVariables.type, error: err.messages })),
+      );
+  };
+
+  return (
+    <DialogContent>
+      <DialogHeader>
+        <DialogTitle>{t("textGeneration.dialogTitle", { type: promptVariables.type })}</DialogTitle>
+        <DialogCloseButton />
+      </DialogHeader>
+      <DialogBody>
+        <CustomPromptsContainer>
+          <FieldRoot>
+            <SwitchRoot
+              checked={customPromptChecked}
+              onCheckedChange={(details) => setCustomPromptChecked(details.checked)}
+            >
+              <SwitchLabel>{t("textGeneration.customPrompts.switchLabel")}</SwitchLabel>
+              <SwitchControl>
+                <SwitchThumb />
+              </SwitchControl>
+              <SwitchHiddenInput />
+            </SwitchRoot>
+          </FieldRoot>
+          {customPromptChecked ? (
+            defaultPromptsQuery.isPending ? (
+              <Spinner size="large" />
+            ) : (
+              <>
+                <FieldRoot>
+                  <FieldLabel>{t("textGeneration.customPrompts.roleLabel")}</FieldLabel>
+                  <FieldHelper>{t("textGeneration.customPrompts.roleHelper")}</FieldHelper>
+                  <FieldTextArea value={rolePrompt} onChange={(e) => setRolePrompt(e.target.value)} />
+                </FieldRoot>
+                <FieldRoot>
+                  <FieldLabel>{t("textGeneration.customPrompts.instructionsLabel")}</FieldLabel>
+                  <FieldHelper>{t("textGeneration.customPrompts.instructionsHelper")}</FieldHelper>
+                  <FieldTextArea value={instructionsPrompt} onChange={(e) => setInstructionsPrompt(e.target.value)} />
+                </FieldRoot>
+              </>
+            )
+          ) : null}
+        </CustomPromptsContainer>
+        {promptVariables.type === "alternativePhrasing" && <StyledText>{promptVariables.text}</StyledText>}
+        <Button size="small" onClick={fetchAiGeneratedText} loading={mutateIsPending}>
+          {t("textGeneration.generateButton", { type: promptVariables.type })}
+          <FileListLine />
+        </Button>
+        <Heading asChild consumeCss textStyle="label.medium">
+          <h2>{t("textGeneration.suggestedText", { type: promptVariables.type })}</h2>
+        </Heading>
+        <StyledText>{generatedText}</StyledText>
+        {error ? (
+          <Text textStyle="label.small" color="text.error">
+            {error}
+          </Text>
+        ) : null}
+        <HStack justify="space-between">
+          <DialogCloseTrigger asChild>
+            <Button size="small" variant="secondary">
+              {t("dialog.close")}
+            </Button>
+          </DialogCloseTrigger>
+          <FormActionsContainer>
+            {!!onInsert && (
+              <DialogCloseTrigger asChild>
+                <Button size="small" onClick={() => onInsert(generatedText)} disabled={!generatedText}>
+                  {t("textGeneration.insert")}
+                </Button>
+              </DialogCloseTrigger>
+            )}
+            {!!onAppend && (
+              <DialogCloseTrigger asChild>
+                <Button size="small" onClick={() => onAppend(generatedText)} disabled={!generatedText}>
+                  {t("textGeneration.append")}
+                </Button>
+              </DialogCloseTrigger>
+            )}
+            {!!onReplace && (
+              <DialogCloseTrigger asChild>
+                <Button size="small" onClick={() => onReplace(generatedText)} disabled={!generatedText}>
+                  {t("textGeneration.replace")}
+                </Button>
+              </DialogCloseTrigger>
+            )}
+          </FormActionsContainer>
+        </HStack>
+      </DialogBody>
+    </DialogContent>
+  );
+};
+
+export const AiPromptDialog = ({
+  promptVariables,
+  language,
+  maxTokens,
+  children,
+  onInsert,
+  onAppend,
+  onReplace,
+  ...rest
+}: DialogRootProps & BaseProps) => (
+  <DialogRoot {...rest}>
+    {children}
+    <Portal>
+      <DialogBackdrop />
path: src/components/AiPromptDialog.tsx
user: login: Jonas-C
body: Denne kan du nok fjerne. Det er en del av `DialogContent`
created_at: 2025-05-22T08:04:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,6 +187,21 @@ const aiMiddleware = (req: Request, res: express.Response, next: express.NextFun
   }
 };
 
+router.get("/default-ai-prompts", jwtMiddleware, aiMiddleware, async (req, res) => {
path: src/server/api.ts
user: login: amatho
body: Ja det gjøres inni `getDefaultPrompts`
created_at: 2025-05-22T09:54:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,6 +187,21 @@ const aiMiddleware = (req: Request, res: express.Response, next: express.NextFun
   }
 };
 
+router.get("/default-ai-prompts", jwtMiddleware, aiMiddleware, async (req, res) => {
path: src/server/api.ts
user: login: amatho
body: Men kanskje returnere bad request i stedet for å fallback'e til NB?
created_at: 2025-05-22T09:55:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: gunnarvelle
created_at: 2025-05-16T10:26:23Z
body: Det hadde vore en fordel å prepopulere prompt-skjema med eksisterende verdier!
author_association: MEMBER
user: login: amatho
created_at: 2025-05-16T12:11:15Z
body: > Det hadde vore en fordel å prepopulere prompt-skjema med eksisterende verdier!

Enig!
author_association: CONTRIBUTOR