Skip to content

Instantly share code, notes, and snippets.

@azizpunjani
Last active February 2, 2026 17:16
Show Gist options
  • Select an option

  • Save azizpunjani/9f27819ddd615374e8c6de43d6bbadb3 to your computer and use it in GitHub Desktop.

Select an option

Save azizpunjani/9f27819ddd615374e8c6de43d6bbadb3 to your computer and use it in GitHub Desktop.
AI Text Transform - Technical Design

AI Text Transform - Technical Design

Overview

A set of reusable AI text transformation tools that any team at Highspot can integrate into their feature.


Goals

  1. Reusable - Any team can integrate AI text transformation
  2. Simple - Dead simple API, minimal learning curve
  3. Flexible - Customize when needed, sensible defaults when not
  4. Consistent - Same UX patterns across products

Architecture

The Two Pieces

useAITextTransform (Hook)

For teams that want full control. Build your own UI, use our AI logic.

Returns:

  • transform(text, { preset: 'shorten' }) - Transform with a preset
  • transform(text, { prompt: 'Custom...' }) - Transform with custom prompt
  • result - The AI-generated text
  • busy - Loading state
  • errors - Error messages
  • cancel - Cancel in-flight request

AIRefinePanel (Component)

For teams that want quick integration. Full UI out of the box, customizable when needed.

Includes:

  • Preset buttons (Shorten, Elaborate, Make it professional) - customizable
  • Result display with label
  • Action buttons (Regenerate, Replace, Add below) - customizable

UI Designs

Inline / Popover Version

When text is selected in an editor, a popover appears with refinement options:

Inline/Popover UI

Modal / Dialog Version

Same content, different container.

Modal/Dialog UI


API Reference

useAITextTransform Hook

const {
  transform,     // (text: string, options: TransformOptions) => void
  result,        // string | null - the AI-generated text
  busy,          // boolean - loading state
  errors,        // string[] - error messages
  cancel,        // () => void - cancel in-flight request
} = useAITextTransform();

Transform Options

transform(text, { preset: 'shorten' });
transform(text, { preset: 'elaborate' });
transform(text, { preset: 'professional' });

Built-in Presets

Preset Description
shorten Condense text while preserving meaning
elaborate Expand with more detail and context
professional Rewrite in business-appropriate tone

Custom Presets

Teams can define their own presets by providing a registered promptKey:

const customPresets = [
  // Built-in presets (just use the key)
  { key: 'shorten', label: 'Shorten' },
  { key: 'elaborate', label: 'Elaborate' },

  // Custom presets (provide a registered promptKey)
  { key: 'simplify', label: 'Simplify', promptKey: 'my_team_simplify_prompt' },
  { key: 'translate', label: 'Spanish', promptKey: 'my_team_translate_spanish' },
];

<AIRefinePanel presets={customPresets} ... />

Note: Custom presets require a promptKey registered in GrowthBook. For fully custom AI interactions, use useGeneralAIRequest directly.


AIRefinePanel Component

interface AIRefinePanelProps {
  // Required
  text: string;                     // The text to transform

  // Action callbacks (provide at least one)
  onReplace?: (text: string) => void;  // Shows "Replace" button if provided
  onInsert?: (text: string) => void;   // Shows "Add below" button if provided

  // Optional
  presets?: Preset[];               // Custom presets (has defaults)
  children?: (props: {              // Custom action buttons
    result: string;
    regenerate: () => void;
  }) => React.ReactNode;
}

Note: Provide at least one of onReplace or onInsert. Buttons are shown based on which callbacks are provided.


Usage Examples

Example 1: Basic Usage (Defaults)

Most common case - use all defaults:

import { AIRefinePanel } from '@highspot/polar-ui';

const MyEditor = () => {
  const [selectedText, setSelectedText] = useState('');

  return (
    <Popover>
      <AIRefinePanel
        text={selectedText}
        onReplace={(newText) => editor.replaceSelection(newText)}
        onInsert={(newText) => editor.insertBelow(newText)}
      />
    </Popover>
  );
};

What you get:

  • Shorten, Elaborate, Make it professional buttons
  • Loading state
  • Error handling
  • Regenerate, Replace, Add below actions

Example 2: In a Modal

Wrap in a modal, add original text display:

import { AIRefinePanel } from '@highspot/polar-ui';
import { Modal } from '@highspot/polar-ui';

const MyModal = ({ isOpen, selectedText, onClose }) => {
  return (
    <Modal open={isOpen} onClose={onClose}>
      <Modal.Header>Refine Text</Modal.Header>

      <Modal.Body>
        {/* Show original - this is YOUR responsibility */}
        <div className="original">
          <label>Original</label>
          <p>{selectedText}</p>
        </div>

        {/* The panel */}
        <AIRefinePanel
          text={selectedText}
          onReplace={(newText) => {
            replaceText(newText);
            onClose();
          }}
          onInsert={(newText) => {
            insertText(newText);
            onClose();
          }}
        />
      </Modal.Body>
    </Modal>
  );
};

Example 3: Custom Presets

Add your own transformation options (requires registered prompt keys):

<AIRefinePanel
  text={selectedText}
  presets={[
    { key: 'shorten', label: 'Shorten' },
    { key: 'elaborate', label: 'Elaborate' },
    { key: 'simplify', label: 'Simplify', promptKey: 'my_team_simplify_prompt' },
    { key: 'translate', label: 'Spanish', promptKey: 'my_team_translate_spanish' },
  ]}
  onReplace={...}
  onInsert={...}
/>

Example 4: Custom Action Buttons

Override the default buttons:

<AIRefinePanel text={selectedText}>
  {({ result, regenerate }) => (
    <div className="my-actions">
      <Button onClick={regenerate}>Try again</Button>
      <Button onClick={() => copyToClipboard(result)}>Copy</Button>
      <Button variant="primary" onClick={() => insertText(result)}>
        Insert
      </Button>
    </div>
  )}
</AIRefinePanel>

Example 5: Full Control with Hook Only

Build completely custom UI using Polar UI components:

import { useAITextTransform } from '@highspot/polar-ui';
import { Button } from '@highspot/polar-ui/Button';
import { Notification } from '@highspot/polar-ui/Notification';
import { Text } from '@highspot/polar-ui/Text';
import { Progress } from '@highspot/polar-ui/Progress';

const MyCustomUI = ({ selectedText, onApply }) => {
  const { transform, result, busy, errors } = useAITextTransform();

  return (
    <div className={styles.container}>
      <Text variant="heading-sm">Refine with AI</Text>

      <div className={styles.buttons}>
        <Button
          onPress={() => transform(selectedText, { preset: 'shorten' })}
          isDisabled={busy}
        >
          Shorten
        </Button>
        <Button
          onPress={() => transform(selectedText, { preset: 'elaborate' })}
          isDisabled={busy}
        >
          Elaborate
        </Button>
      </div>

      {busy && <Progress size="small" aria-label="Processing" />}

      {errors.length > 0 && (
        <Notification purpose="danger">{errors.join(', ')}</Notification>
      )}

      {result && (
        <div className={styles.result}>
          <Text>{result}</Text>
          <Button purpose="cta" onPress={() => onApply(result)}>
            Apply
          </Button>
        </div>
      )}
    </div>
  );
};

Integration Guide

For SmartPages (Lexical Editor)

Create a Lexical plugin that wraps AIRefinePanel:

// AIRefinePlugin.tsx
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useFloating } from '@floating-ui/react';
import { AIRefinePanel } from '@highspot/polar-ui';

export const AIRefinePlugin = () => {
  const [editor] = useLexicalComposerContext();
  const [selectedText, setSelectedText] = useState('');
  const [isVisible, setIsVisible] = useState(false);

  // Track selection...
  // Position popover...

  if (!isVisible) return null;

  return createPortal(
    <div ref={refs.setFloating} style={floatingStyles}>
      <AIRefinePanel
        text={selectedText}
        onReplace={(newText) => {
          editor.update(() => {
            const selection = $getSelection();
            selection.insertText(newText);
          });
          setIsVisible(false);
        }}
        onInsert={(newText) => {
          editor.update(() => {
            // Insert below logic
          });
          setIsVisible(false);
        }}
      />
    </div>,
    document.body
  );
};

Technical Details

How It Works Under the Hood

Flow Diagram

API Endpoint

Uses the existing AI services infrastructure:

  • Endpoint: /api/v1/ai/llm/general
  • Method: POST
  • Auth: User session (domain_id, user_id)

Prompt Registration Requirement

Important: The public API endpoint (production) requires prompt_key - prompts must be pre-registered in the experimentation service. Raw prompt_txt is only supported on the internal endpoint (development with VPN).

This means:

  1. Built-in presets (shorten, elaborate, professional) must be registered as prompt keys
  2. Custom presets with inline prompts work in development but require registration for production

For v1:

  • Register the three built-in preset prompts in experimentation service
  • Custom presets that need production support must also be registered

Prompt Keys to Register (via GrowthBook):

Key Prompt Template
ai_refine_shorten Shorten this text while preserving meaning. Return only the result:\n\n{request{text}}
ai_refine_elaborate Elaborate with more detail and context. Return only the result:\n\n{request{text}}
ai_refine_professional Rewrite in a professional business tone. Return only the result:\n\n{request{text}}

Prompts are registered in GrowthBook and use {request{field}} syntax for variable substitution.

No Streaming (v1)

The API returns the full response at once (not streamed). This keeps things simple:

  • Show loading spinner
  • Response arrives
  • Show result

Streaming can be added later if needed.


Decision Log

Decision Rationale
2 exports only (hook + panel) Simplicity. Teams use panel for quick integration, hook for full control.
Presets as prop, not hardcoded Teams can customize without forking.
Children render prop for actions Default buttons work for most, custom when needed.
No "Original" in panel Modal vs inline have different needs. Consumer handles this.
Non-streaming v1 Simpler. Responses are short anyway.
Registered prompts via prompt_key Public API requires registered prompts. Built-in presets registered in experimentation service.

File Structure

packages/polar-ui/src/AIRefine/
├── index.ts                    # Exports
├── useAITextTransform.ts       # The hook
├── AIRefinePanel.tsx           # The component
├── AIRefinePanel.module.scss   # Styles
├── types.ts                    # TypeScript types
├── __tests__/
│   ├── useAITextTransform.test.ts
│   └── AIRefinePanel.test.tsx
└── stories/
    └── AIRefinePanel.stories.tsx

JIRA Breakdown

Ticket Description Team
1 Register prompts in GrowthBook & deploy to SUs AI Services
2 Create useAITextTransform hook Polar UI
3 Create AIRefinePanel component Polar UI
4 Add Storybook stories Polar UI
5 Create Lexical AIRefinePlugin SmartPages
6 Feature flag setup SmartPages
7 E2E tests SmartPages

Open Questions

  1. Should we add more default presets? (summarize, translate, etc.)
  2. Do we need analytics/tracking built in?
  3. Should the panel support a "compact" mode for smaller spaces?

Appendix: Full Code

useAITextTransform.ts

import { useCallback, useState } from 'react';
import useGeneralAIRequest from '~/features/shared/components/ai/RoutesAIServices/hooks/useGeneralAIRequest';
import { getCurrentUser } from '~/helpers/CurrentUser';

// Built-in presets map to registered prompt_keys
const BUILT_IN_PROMPT_KEYS: Record<string, string> = {
  shorten: 'ai_refine_shorten',
  elaborate: 'ai_refine_elaborate',
  professional: 'ai_refine_professional',
};

interface TransformOptions {
  preset?: string;      // Built-in preset key
  promptKey?: string;   // Custom registered prompt key
}

export const useAITextTransform = () => {
  const currentUser = getCurrentUser();
  const [result, setResult] = useState<string | null>(null);
  const [activePreset, setActivePreset] = useState<string | null>(null);

  const { onSubmit, busy, errors, onCancel, setErrors } = useGeneralAIRequest({
    onSuccess: (response) => {
      setResult(response?.choices?.[0]?.text ?? null);
    },
  });

  const transform = useCallback(
    (text: string, options: TransformOptions) => {
      // Resolve prompt_key: built-in preset OR custom promptKey
      const promptKey = options.promptKey ?? BUILT_IN_PROMPT_KEYS[options.preset!];

      if (!promptKey) {
        setErrors(['Invalid preset or missing promptKey']);
        return;
      }

      setResult(null);
      setActivePreset(options.preset ?? 'custom');

      onSubmit({
        domainId: currentUser.get('domain_id'),
        userId: currentUser.get('id'),
        requestProps: {
          prompt_key: promptKey,
          text,  // passed as variable for prompt template resolution
        },
        args: { temperature: 0.3, enable_event_logging: true },
      });
    },
    [currentUser, onSubmit, setErrors]
  );

  const reset = useCallback(() => {
    setResult(null);
    setActivePreset(null);
    setErrors([]);
  }, [setErrors]);

  return {
    transform,
    result,
    activePreset,
    busy,
    errors,
    cancel: onCancel,
    reset,
  };
};

AIRefinePanel.tsx

import { useState } from 'react';
import { useAITextTransform } from './useAITextTransform';
import styles from './AIRefinePanel.module.scss';

const DEFAULT_PRESETS = [
  { key: 'shorten', label: 'Shorten' },
  { key: 'elaborate', label: 'Elaborate' },
  { key: 'professional', label: 'Make it professional' },
];

const LABELS: Record<string, string> = {
  shorten: 'Shortened',
  elaborate: 'Elaborated',
  professional: 'Professional',
};

interface Preset {
  key: string;
  label: string;
  promptKey?: string;  // Required for custom presets (must be registered in GrowthBook)
}

interface Props {
  text: string;
  presets?: Preset[];
  onReplace: (text: string) => void;
  onInsert: (text: string) => void;
  onCancel?: () => void;
  children?: (props: { result: string; regenerate: () => void }) => React.ReactNode;
}

export const AIRefinePanel = ({
  text,
  presets = DEFAULT_PRESETS,
  onReplace,
  onInsert,
  onCancel,
  children,
}: Props) => {
  const { transform, result, activePreset, busy, errors, reset } = useAITextTransform();
  const [lastPreset, setLastPreset] = useState<Preset | null>(null);

  const handleSelect = (preset: Preset) => {
    setLastPreset(preset);
    transform(text, preset.promptKey ? { promptKey: preset.promptKey } : { preset: preset.key });
  };

  const handleRegenerate = () => {
    if (lastPreset) handleSelect(lastPreset);
  };

  return (
    <div className={styles.panel}>
      {/* Header */}
      <div className={styles.header}>
        <span className={styles.icon}></span>
        <span>Refine text</span>
      </div>

      {/* Preset Buttons */}
      <div className={styles.menu}>
        {presets.map((preset) => (
          <button
            key={preset.key}
            className={styles.presetButton}
            onClick={() => handleSelect(preset)}
            disabled={busy}
          >
            {preset.label}
          </button>
        ))}
      </div>

      {/* Loading */}
      {busy && <div className={styles.loading}>Processing...</div>}

      {/* Errors */}
      {errors.length > 0 && (
        <div className={styles.error}>{errors.join(', ')}</div>
      )}

      {/* Result */}
      {result && (
        <div className={styles.result}>
          <span className={styles.label}>
            {LABELS[activePreset!] || activePreset}
          </span>
          <p className={styles.text}>{result}</p>

          {/* Actions */}
          {children ? (
            children({ result, regenerate: handleRegenerate })
          ) : (
            <div className={styles.actions}>
              <button onClick={handleRegenerate}>Regenerate</button>
              <div className={styles.rightActions}>
                <button onClick={() => onReplace(result)}>Replace</button>
                <button
                  className={styles.primary}
                  onClick={() => onInsert(result)}
                >
                  Add below
                </button>
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment