Analysis of implementing the AI Text Transform feature for Highspot, including streaming capabilities.
Based on the original technical design gist.
For teams wanting full control over UI.
Returns:
reset- Reset statecancel- Cancel in-flight requesterrors- Error messagesbusy- Loading stateresult- The AI-generated texttransform(text, options)- Transform with preset or custom promptKey
Built-in Presets:
| Preset | Prompt Key |
|---|---|
shorten |
ai_refine_shorten |
elaborate |
ai_refine_elaborate |
professional |
ai_refine_professional |
Full UI out of the box with:
- Error handling & loading state
- Result display with label
- Action buttons (Regenerate, Replace, Add below)
- Preset buttons (Shorten, Elaborate, Make it professional)
Streaming is exposed as an explicit option, not a hidden feature flag:
const { transform, result, busy } = useAITextTransform({ streaming: true });Rationale:
- Components are designed for either progressive updates or single updates
- Consumers should be aware of re-render implications (~50-200 updates vs 2)
- Explicit opt-in is more predictable than silent behavior changes
Internal Implementation:
- Single hook with unified API
- Two separate code paths (
transformStreaming/transformNonStreaming) - Shared state management (result, busy, errors, cancel, reset)
Consumer Experience:
| Non-Streaming | Streaming | |
|---|---|---|
result updates |
Once (null → full text) | Incrementally (null → "The" → "The quick" → ...) |
| Re-renders | ~2 | ~50-200 |
| Perceived speed | Waits, then shows all | Text appears immediately |
The /api/v1/ai/llm/general endpoint already supports streaming via query parameters:
POST /api/v1/ai/llm/general?streaming=true&stream_format=sse
Use existing useGeneralAIRequest hook:
import { useCallback, useRef, useState } from 'react';
import { getCurrentUser } from '~/helpers/CurrentUser';
import useGeneralAIRequest from '~/features/shared/components/ai/RoutesAIServices/hooks/useGeneralAIRequest';
const PRESETS: Record<string, string> = {
shorten: 'ai_refine_shorten',
elaborate: 'ai_refine_elaborate',
professional: 'ai_refine_professional',
};
type TransformOptions = {
preset?: keyof typeof PRESETS;
promptKey?: 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 promptKey = options.promptKey ?? PRESETS[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 },
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 };
};Same hook API, with streaming as an explicit option:
import { useCallback, useRef, useState } from 'react';
import { getCurrentUser } from '~/helpers/CurrentUser';
import useGeneralAIRequest from '~/features/shared/components/ai/RoutesAIServices/hooks/useGeneralAIRequest';
import RoutesAIServices from '~/features/shared/components/ai/RoutesAIServices';
const PRESETS: Record<string, string> = {
shorten: 'ai_refine_shorten',
elaborate: 'ai_refine_elaborate',
professional: 'ai_refine_professional',
};
type TransformOptions = {
preset?: keyof typeof PRESETS;
promptKey?: string;
};
type UseAITextTransformOptions = {
streaming?: boolean;
};
export const useAITextTransform = ({ streaming = false }: UseAITextTransformOptions = {}) => {
const currentUser = getCurrentUser();
const [result, setResult] = useState<string | null>(null);
const [activePreset, setActivePreset] = useState<string | null>(null);
const [streamingBusy, setStreamingBusy] = useState(false);
const [streamingErrors, setStreamingErrors] = useState<string[]>([]);
const abortRef = useRef<AbortController | null>(null);
const { onSubmit, busy: nonStreamingBusy, errors: nonStreamingErrors, onCancel, setErrors } = useGeneralAIRequest({
onSuccess: (response) => {
setResult(response?.choices?.[0]?.text ?? null);
},
});
const transformNonStreaming = useCallback((text: string, promptKey: string) => {
setResult(null);
onSubmit({
domainId: currentUser.get('domain_id'),
userId: currentUser.get('id'),
requestProps: { prompt_key: promptKey, text },
args: { temperature: 0.3, enable_event_logging: true },
});
}, [currentUser, onSubmit]);
const transformStreaming = useCallback(async (text: string, promptKey: string) => {
setResult('');
setStreamingBusy(true);
setStreamingErrors([]);
abortRef.current = new AbortController();
const url = `${RoutesAIServices.general()}?streaming=true&stream_format=sse`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'HS-CSRF': window.hs_csrf || '',
},
credentials: 'include',
body: JSON.stringify({
domain_id: currentUser.get('domain_id'),
user_id: currentUser.get('id'),
prompt_key: promptKey,
text,
args: { temperature: 0.3, enable_event_logging: true },
}),
signal: abortRef.current.signal,
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (reader) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\r\n\r\n');
buffer = events.pop() || '';
for (const event of events) {
if (!event.trim()) continue;
let eventName: string | undefined;
let eventData: string | undefined;
for (const line of event.split('\r\n')) {
if (line.startsWith('event: ')) eventName = line.slice(7);
if (line.startsWith('data: ')) eventData = line.slice(6);
}
if (eventData) {
const parsed = JSON.parse(eventData);
if (eventName === 'token' && parsed.content) {
setResult(prev => (prev ?? '') + parsed.content);
} else if (eventName === 'stream_error') {
setStreamingErrors([parsed.error || 'Stream error']);
}
}
}
}
} catch (err: any) {
if (err.name !== 'AbortError') {
setStreamingErrors([err.message || 'Unknown error']);
}
} finally {
setStreamingBusy(false);
}
}, [currentUser]);
const transform = useCallback((text: string, options: TransformOptions) => {
const promptKey = options.promptKey ?? PRESETS[options.preset!];
if (!promptKey) {
setErrors(['Invalid preset or missing promptKey']);
return;
}
setActivePreset(options.preset ?? 'custom');
if (streaming) {
transformStreaming(text, promptKey);
} else {
transformNonStreaming(text, promptKey);
}
}, [streaming, transformStreaming, transformNonStreaming, setErrors]);
const cancel = useCallback(() => {
if (streaming) {
abortRef.current?.abort();
setStreamingBusy(false);
} else {
onCancel();
}
}, [streaming, onCancel]);
const reset = useCallback(() => {
setResult(null);
setActivePreset(null);
setStreamingErrors([]);
setErrors([]);
}, [setErrors]);
return {
transform,
result,
activePreset,
busy: streaming ? streamingBusy : nonStreamingBusy,
errors: streaming ? streamingErrors : nonStreamingErrors,
cancel,
reset,
};
};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
py/shared/shared/handlers/streaming_callback_handler.py- SSE event formattingpy/llmproxy/llmproxy/api/endpoints/general.py- Streaming endpoint implementation
features/training/trainingShared/modals/RolePlayScenarioModal/hooks/useGenerateScenario.ts- Example general API usagefeatures/copilot/utility/streaming/streamCopilotAnswer.ts- AG-UI streaming examplefeatures/copilot/components/CopilotCenter/agent-platform/chatui/ChatUIStreamClient.ts- SSE streaming patternsfeatures/shared/components/ai/RoutesAIServices/hooks/useGeneralAIRequest.js- Existing non-streaming hook
| Task | Effort |
|---|---|
| Backend changes | None needed - streaming already supported |
| Frontend hook (non-streaming) | ~0.5 day |
| Frontend hook (streaming) | ~1 day |
| AIRefinePanel component | ~0.5 day |
| Testing | ~0.5 day |
| Total | ~2.5 days |
| Ticket | Description | Team |
|---|---|---|
| 1 | Register prompts in GrowthBook & deploy to SUs | AI Services |
| 2 | Create useAITextTransform hook (non-streaming) |
Polar UI |
| 3 | Add streaming support to hook | Polar UI |
| 4 | Create AIRefinePanel component |
Polar UI |
| 5 | Add Storybook stories | Polar UI |
| 6 | Create Lexical AIRefinePlugin |
SmartPages |
| 7 | Feature flag setup | SmartPages |
| 8 | 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?
Generated from conversation analysis on Feb 5, 2026