A set of reusable AI text transformation tools that any team at Highspot can integrate into their feature.
- Reusable - Any team can integrate AI text transformation
- Simple - Dead simple API, minimal learning curve
- Flexible - Customize when needed, sensible defaults when not
- Consistent - Same UX patterns across products
For teams that want full control. Build your own UI, use our AI logic.
Returns:
transform(text, { preset: 'shorten' })- Transform with a presettransform(text, { prompt: 'Custom...' })- Transform with custom promptresult- The AI-generated textbusy- Loading stateerrors- Error messagescancel- Cancel in-flight request
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
When text is selected in an editor, a popover appears with refinement options:
Same content, different container.
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(text, { preset: 'shorten' });
transform(text, { preset: 'elaborate' });
transform(text, { preset: 'professional' });| Preset | Description |
|---|---|
shorten |
Condense text while preserving meaning |
elaborate |
Expand with more detail and context |
professional |
Rewrite in business-appropriate tone |
Teams can define their own presets by providing a prompt in the preset definition:
const customPresets = [
// Built-in presets (just use the key)
{ key: 'shorten', label: 'Shorten' },
{ key: 'elaborate', label: 'Elaborate' },
// Custom presets (provide a prompt)
{ key: 'simplify', label: 'Simplify', prompt: 'Rewrite this for a 5th grader:' },
{ key: 'spanish', label: 'Spanish', prompt: 'Translate to Spanish:' },
];
<AIRefinePanel presets={customPresets} ... />Note: For fully custom AI interactions beyond text refinement, use
useGeneralAIRequestdirectly.
interface AIRefinePanelProps {
// Required
text: string; // The text to transform
onReplace: (text: string) => void; // Called when user clicks Replace
onInsert: (text: string) => void; // Called when user clicks Add below
// Optional
onCancel?: () => void; // Called when user cancels
presets?: Preset[]; // Custom presets (has defaults)
children?: RenderProp; // Custom action buttons
}Most common case - use all defaults:
import { AIRefinePanel } from '@highspot/ai-refine';
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
Wrap in a modal, add original text display:
import { AIRefinePanel } from '@highspot/ai-refine';
import { Modal } from '@highspot/polar';
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>
);
};Add your own transformation options:
<AIRefinePanel
text={selectedText}
presets={[
{ key: 'shorten', label: 'Shorten' },
{ key: 'elaborate', label: 'Elaborate' },
{ key: 'simplify', label: 'Simplify', prompt: 'Rewrite for a 5th grader:' },
{ key: 'translate', label: 'Spanish', prompt: 'Translate to Spanish:' },
]}
onReplace={...}
onInsert={...}
/>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>Build completely custom UI:
import { useAITextTransform } from '@highspot/ai-refine';
const MyCustomUI = ({ selectedText }) => {
const { transform, result, busy, errors } = useAITextTransform();
return (
<div className="my-custom-design">
<h3>AI Magic</h3>
<div className="buttons">
<MyFancyButton
onClick={() => transform(selectedText, { preset: 'shorten' })}
loading={busy}
>
Make it shorter
</MyFancyButton>
<MyFancyButton
onClick={() => transform(selectedText, { prompt: 'Add emojis:' })}
loading={busy}
>
Add emojis
</MyFancyButton>
</div>
{errors.length > 0 && <MyErrorBanner errors={errors} />}
{result && (
<MyResultCard>
<p>{result}</p>
<MyFancyButton onClick={() => applyResult(result)}>
Use this
</MyFancyButton>
</MyResultCard>
)}
</div>
);
};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/ai-refine';
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);
}}
onCancel={() => setIsVisible(false)}
/>
</div>,
document.body
);
};Uses the existing AI services infrastructure:
- Endpoint:
/api/v1/ai/llm/general - Method: POST
- Auth: User session (domain_id, user_id)
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 | 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. |
| Frontend prompts (not registered) | Faster iteration, no backend dependency for v1. |
features/shared/components/ai/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
| Ticket | Description | Team |
|---|---|---|
| 1 | Create useAITextTransform hook |
Polar UI |
| 2 | Create AIRefinePanel component |
Polar UI |
| 3 | Add Storybook stories | Polar UI |
| 4 | Create Lexical AIRefinePlugin |
SmartPages |
| 5 | Feature flag setup | SmartPages |
| 6 | E2E tests | SmartPages |
- Should we add more default presets? (summarize, translate, etc.)
- Do we need analytics/tracking built in?
- Should the panel support a "compact" mode for smaller spaces?
import { useCallback, useState } from 'react';
import useGeneralAIRequest from '~/features/shared/components/ai/RoutesAIServices/hooks/useGeneralAIRequest';
import { getCurrentUser } from '~/helpers/CurrentUser';
const PRESETS: Record<string, string> = {
shorten: 'Shorten this text while preserving meaning. Return only the result:',
elaborate: 'Elaborate with more detail. Return only the result:',
professional: 'Rewrite in a professional tone. Return only the result:',
};
interface TransformOptions {
preset?: string;
prompt?: string;
}
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) => {
const promptText = options.prompt ?? PRESETS[options.preset!];
if (!promptText) {
setErrors(['Provide preset or custom prompt']);
return;
}
setResult(null);
setActivePreset(options.preset ?? 'custom');
onSubmit({
domainId: currentUser.get('domain_id'),
userId: currentUser.get('id'),
requestProps: { prompt_text: `${promptText}\n\n${text}` },
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,
};
};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;
prompt?: string;
}
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.prompt ? { prompt: preset.prompt } : { 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>
);
};

