diff --git a/package.json b/package.json index 8a0d67c194..3e8e1666bb 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "validate-llms-txt": "node bin/validate-llms.txt.ts" }, "dependencies": { - "@ably/ui": "17.11.4", + "@ably/ui": "17.13.1-dev.c839343a", "@codesandbox/sandpack-react": "^2.20.0", "@codesandbox/sandpack-themes": "^2.0.21", "@gfx/zopfli": "^1.0.15", diff --git a/src/components/Layout/LanguageSelector.tsx b/src/components/Layout/LanguageSelector.tsx index 00646c0471..d950243924 100644 --- a/src/components/Layout/LanguageSelector.tsx +++ b/src/components/Layout/LanguageSelector.tsx @@ -8,7 +8,7 @@ import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from '@ably/u import { track } from '@ably/ui/core/insights'; import { languageData, languageInfo } from 'src/data/languages'; import { LanguageKey } from 'src/data/languages/types'; -import { useLayoutContext } from 'src/contexts/layout-context'; +import { useLayoutContext, FE_LANGUAGES, BE_LANGUAGES } from 'src/contexts/layout-context'; import { navigate } from '../Link'; import { LANGUAGE_SELECTOR_HEIGHT, INKEEP_ASK_BUTTON_HEIGHT } from './utils/heights'; import * as Select from '../ui/Select'; @@ -20,7 +20,7 @@ type LanguageSelectorOptionData = { version: string; }; -export const LanguageSelector = () => { +const SingleLanguageSelector = () => { const { activePage } = useLayoutContext(); const location = useLocation(); const languageVersions = languageData[activePage.product ?? 'pubsub']; @@ -163,3 +163,193 @@ export const LanguageSelector = () => { ); }; + +type DualLanguageDropdownProps = { + label: string; + paramName: 'fe_lang' | 'be_lang'; + languages: LanguageKey[]; + selectedLanguage: LanguageKey | null; +}; + +const DualLanguageDropdown = ({ label, paramName, languages, selectedLanguage }: DualLanguageDropdownProps) => { + const { activePage } = useLayoutContext(); + const location = useLocation(); + const languageVersions = languageData[activePage.product ?? 'aiTransport']; + + const options: LanguageSelectorOptionData[] = useMemo( + () => + languages + .filter((lang) => languageVersions[lang]) + .map((lang) => ({ + label: lang, + value: `${lang}-${languageVersions[lang]}`, + version: languageVersions[lang], + })), + [languages, languageVersions], + ); + + const [value, setValue] = useState(''); + + useEffect(() => { + const defaultOption = options.find((option) => option.label === selectedLanguage) || options[0]; + if (defaultOption) { + setValue(defaultOption.value); + } + }, [selectedLanguage, options]); + + const selectedOption = useMemo(() => options.find((option) => option.value === value), [options, value]); + + const handleValueChange = (newValue: string) => { + setValue(newValue); + + const option = options.find((opt) => opt.value === newValue); + if (option) { + track('language_selector_changed', { + language: option.label, + type: paramName, + location: location.pathname, + }); + + // Preserve existing URL params and update the relevant one + const params = new URLSearchParams(location.search); + params.set(paramName, option.label); + navigate(`${location.pathname}?${params.toString()}`); + } + }; + + if (!selectedOption) { + return ; + } + + const selectedLang = languageInfo[selectedOption.label]; + + return ( +
+ + {label} + + + 1 ? 'cursor-pointer' : 'cursor-auto', + )} + style={{ height: LANGUAGE_SELECTOR_HEIGHT }} + aria-label={`Select ${label.toLowerCase()} language`} + disabled={options.length === 1} + > +
+ + {selectedLang?.label} + + v{selectedOption.version} + +
+ {options.length > 1 && ( + + + + )} +
+ + + + +

{label}

+ {options.map((option) => { + const lang = languageInfo[option.label]; + return ( + +
+ + {lang?.label} +
+ + v{option.version} + + {option.value === value ? ( + + + + ) : ( +
+ )} + + ); + })} + + + + +
+ ); +}; + +const DualLanguageSelector = () => { + const { activePage } = useLayoutContext(); + + return ( +
+ + +
+ ); +}; + +// Main export - renders appropriate selector based on page type +export const LanguageSelector = () => { + const { activePage } = useLayoutContext(); + + if (activePage.isDualLanguage) { + return ; + } + + return ; +}; diff --git a/src/components/Layout/LeftSidebar.test.tsx b/src/components/Layout/LeftSidebar.test.tsx index c7efeacfcb..2a2610a411 100644 --- a/src/components/Layout/LeftSidebar.test.tsx +++ b/src/components/Layout/LeftSidebar.test.tsx @@ -16,6 +16,7 @@ jest.mock('src/contexts/layout-context', () => ({ template: null, }, }), + isDualLanguagePath: jest.fn().mockReturnValue(false), })); jest.mock('@reach/router', () => ({ diff --git a/src/components/Layout/LeftSidebar.tsx b/src/components/Layout/LeftSidebar.tsx index 0574666f77..bf6a416c5c 100644 --- a/src/components/Layout/LeftSidebar.tsx +++ b/src/components/Layout/LeftSidebar.tsx @@ -8,9 +8,36 @@ import Icon from '@ably/ui/core/Icon'; import { productData } from 'src/data'; import { NavProductContent, NavProductPage } from 'src/data/nav/types'; import Link from '../Link'; -import { useLayoutContext } from 'src/contexts/layout-context'; +import { useLayoutContext, isDualLanguagePath } from 'src/contexts/layout-context'; import { interactiveButtonClassName } from './utils/styles'; +// Build link with appropriate language params based on target page type +const buildLinkWithParams = (targetLink: string, searchParams: URLSearchParams): string => { + const feLang = searchParams.get('fe_lang'); + const beLang = searchParams.get('be_lang'); + const lang = searchParams.get('lang'); + + const params = new URLSearchParams(); + + if (isDualLanguagePath(targetLink)) { + // Target supports dual language - preserve fe_lang/be_lang + if (feLang) { + params.set('fe_lang', feLang); + } + if (beLang) { + params.set('be_lang', beLang); + } + } else { + // Target uses single language - preserve lang + if (lang) { + params.set('lang', lang); + } + } + + const paramString = params.toString(); + return paramString ? `${targetLink}?${paramString}` : targetLink; +}; + type LeftSidebarProps = { className?: string; inHeader?: boolean; @@ -78,7 +105,7 @@ const ChildAccordion = ({ content, tree }: { content: (NavProductPage | NavProdu } }, [activePage.tree.length, subtreeIdentifier]); - const lang = new URLSearchParams(location.search).get('lang'); + const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]); return ( {page.name} {page.external && ( diff --git a/src/components/Layout/MDXWrapper.tsx b/src/components/Layout/MDXWrapper.tsx index 492501d799..ec9ee81ade 100644 --- a/src/components/Layout/MDXWrapper.tsx +++ b/src/components/Layout/MDXWrapper.tsx @@ -103,54 +103,95 @@ const WrappedCodeSnippet: React.FC<{ activePage: ActivePage } & CodeSnippetProps return processChild(children); }, [children, replacements]); - // Check if this code block contains only a single utility language - const utilityLanguageOverride = useMemo(() => { + // Detect code block type (fe_, be_, utility, or standard) + const { languageOverride, detectedSdkType } = useMemo(() => { // Utility languages that should be shown without warning (like JSON) - const UTILITY_LANGUAGES = ['html', 'xml', 'css', 'sql', 'json']; + const UTILITY_LANGUAGES = ['html', 'xml', 'css', 'sql', 'json', 'shell', 'text']; - const childrenArray = React.Children.toArray(processedChildren); + // Helper to extract language from className + const extractLangFromClassName = (className: string | undefined): string | null => { + if (!className) { + return null; + } + const langMatch = className.match(/language-(\S+)/); + return langMatch ? langMatch[1] : null; + }; - // Check if this is a single child with a utility language - if (childrenArray.length !== 1) { - return null; - } + // Recursively find all language classes in children + const findLanguages = (node: ReactNode): string[] => { + const languages: string[] = []; + + React.Children.forEach(node, (child) => { + if (!isValidElement(child)) { + return; + } + + const element = child as ReactElement; + const props = element.props || {}; + + // Check className on this element + const lang = extractLangFromClassName(props.className); + if (lang) { + languages.push(lang); + } + + // Recursively check children + if (props.children) { + languages.push(...findLanguages(props.children)); + } + }); - const child = childrenArray[0]; - if (!isValidElement(child)) { - return null; + return languages; + }; + + const languages = findLanguages(processedChildren); + + // Check for fe_/be_ prefixes + const hasFEPrefix = languages.some((lang) => lang.startsWith('fe_')); + const hasBEPrefix = languages.some((lang) => lang.startsWith('be_')); + + if (hasFEPrefix && activePage.isDualLanguage) { + return { languageOverride: activePage.feLanguage, detectedSdkType: 'fe' as SDKType }; } - const preElement = child as ReactElement; - const codeElement = isValidElement(preElement.props?.children) - ? (preElement.props.children as ReactElement) - : null; + if (hasBEPrefix && activePage.isDualLanguage) { + return { languageOverride: activePage.beLanguage, detectedSdkType: 'be' as SDKType }; + } - if (!codeElement || !codeElement.props.className) { - return null; + // Check for single utility language (existing logic) + if (languages.length === 1 && UTILITY_LANGUAGES.includes(languages[0])) { + return { languageOverride: languages[0], detectedSdkType: null }; } - const className = codeElement.props.className as string; - const langMatch = className.match(/language-(\w+)/); - const lang = langMatch ? langMatch[1] : null; + return { languageOverride: null, detectedSdkType: null }; + }, [processedChildren, activePage.isDualLanguage, activePage.feLanguage, activePage.beLanguage]); + + // For fe/be blocks, the page-level selector controls language, so disable internal onChange + const handleLanguageChange = (lang: string, newSdk: SDKType | undefined) => { + // Don't navigate for fe/be blocks - page-level selector handles this + if (detectedSdkType === 'fe' || detectedSdkType === 'be') { + return; + } - // If it's a utility language, return the language to use as override - return lang && UTILITY_LANGUAGES.includes(lang) ? lang : null; - }, [processedChildren]); + if (!detectedSdkType) { + setSdk(newSdk ?? null); + } + navigate(`${location.pathname}?lang=${lang}`); + }; return ( { - setSdk(sdk ?? null); - navigate(`${location.pathname}?lang=${lang}`); - }} + lang={languageOverride || activePage.language} + sdk={detectedSdkType || sdk} + onChange={handleLanguageChange} className={cn(props.className, 'mb-5')} languageOrdering={ activePage.product && languageData[activePage.product] ? Object.keys(languageData[activePage.product]) : [] } apiKeys={apiKeys} + // Hide internal language selector for fe/be blocks since page-level selector controls it + fixed={detectedSdkType === 'fe' || detectedSdkType === 'be'} > {processedChildren} diff --git a/src/components/Layout/mdx/If.tsx b/src/components/Layout/mdx/If.tsx index ada93c3dee..8cb15eb407 100644 --- a/src/components/Layout/mdx/If.tsx +++ b/src/components/Layout/mdx/If.tsx @@ -5,15 +5,18 @@ import UserContext from 'src/contexts/user-context'; interface IfProps { lang?: LanguageKey; + fe_lang?: LanguageKey; + be_lang?: LanguageKey; + fe_or_be_lang?: LanguageKey; loggedIn?: boolean; className?: string; children: React.ReactNode; as?: React.ElementType; } -const If: React.FC = ({ lang, loggedIn, children }) => { +const If: React.FC = ({ lang, fe_lang, be_lang, fe_or_be_lang, loggedIn, children }) => { const { activePage } = useLayoutContext(); - const { language } = activePage; + const { language, feLanguage, beLanguage } = activePage; const userContext = useContext(UserContext); let shouldShow = true; @@ -24,6 +27,26 @@ const If: React.FC = ({ lang, loggedIn, children }) => { shouldShow = shouldShow && splitLang.includes(language); } + // Check frontend language condition if fe_lang prop is provided + if (fe_lang !== undefined && feLanguage) { + const splitLang = fe_lang.split(','); + shouldShow = shouldShow && splitLang.includes(feLanguage); + } + + // Check backend language condition if be_lang prop is provided + if (be_lang !== undefined && beLanguage) { + const splitLang = be_lang.split(','); + shouldShow = shouldShow && splitLang.includes(beLanguage); + } + + // Check if either fe or be matches (OR logic) - useful for shared requirements + if (fe_or_be_lang !== undefined) { + const splitLang = fe_or_be_lang.split(','); + const feMatches = feLanguage && splitLang.includes(feLanguage); + const beMatches = beLanguage && splitLang.includes(beLanguage); + shouldShow = shouldShow && (feMatches || beMatches); + } + // Check logged in condition if loggedIn prop is provided if (loggedIn !== undefined && userContext.sessionState !== undefined) { const isSignedIn = userContext.sessionState.signedIn ?? false; diff --git a/src/components/Layout/mdx/PageHeader.tsx b/src/components/Layout/mdx/PageHeader.tsx index 19b5eb92f6..9b6c083400 100644 --- a/src/components/Layout/mdx/PageHeader.tsx +++ b/src/components/Layout/mdx/PageHeader.tsx @@ -39,11 +39,14 @@ export const PageHeader: React.FC = ({ title, intro }) => { const showLanguageSelector = useMemo( () => - activePage.languages.length > 0 && - !activePage.languages.every( - (language) => !Object.keys(languageData[product as ProductKey] ?? {}).includes(language), - ), - [activePage.languages, product], + // Always show for dual language pages (AI Transport guides) + activePage.isDualLanguage || + // Standard logic: show if languages exist and at least one is in languageData + (activePage.languages.length > 0 && + !activePage.languages.every( + (language) => !Object.keys(languageData[product as ProductKey] ?? {}).includes(language), + )), + [activePage.languages, product, activePage.isDualLanguage], ); useEffect(() => { diff --git a/src/components/Layout/utils/nav.ts b/src/components/Layout/utils/nav.ts index 3441a1c267..92a9718f02 100644 --- a/src/components/Layout/utils/nav.ts +++ b/src/components/Layout/utils/nav.ts @@ -14,6 +14,10 @@ export type ActivePage = { language: LanguageKey | null; product: ProductKey | null; template: PageTemplate; + // Dual language support for AI Transport guides + feLanguage?: LanguageKey | null; + beLanguage?: LanguageKey | null; + isDualLanguage?: boolean; }; /** diff --git a/src/contexts/layout-context.tsx b/src/contexts/layout-context.tsx index a81e7a594a..d20746dc11 100644 --- a/src/contexts/layout-context.tsx +++ b/src/contexts/layout-context.tsx @@ -16,6 +16,21 @@ import { ProductKey } from 'src/data/types'; export const DEFAULT_LANGUAGE = 'javascript'; +// Languages supported for dual-language selection in AI Transport guides +export const FE_LANGUAGES: LanguageKey[] = ['javascript', 'swift', 'java']; +export const BE_LANGUAGES: LanguageKey[] = ['javascript', 'python', 'java']; + +// Check if a page supports dual language selection based on its path +// Used for navigation param preservation (we don't have access to page content at nav time) +export const isDualLanguagePath = (pathname: string): boolean => { + return pathname.includes('/docs/guides/ai-transport'); +}; + +// Check if page content has fe_/be_ prefixed languages (more accurate than path check) +const hasDualLanguageContent = (languages: string[]): boolean => { + return languages.some((lang) => lang.startsWith('fe_') || lang.startsWith('be_')); +}; + const LayoutContext = createContext<{ activePage: ActivePage; }>({ @@ -26,6 +41,9 @@ const LayoutContext = createContext<{ language: DEFAULT_LANGUAGE, product: null, template: null, + feLanguage: null, + beLanguage: null, + isDualLanguage: false, }, }); @@ -47,6 +65,32 @@ const determineActiveLanguage = ( return DEFAULT_LANGUAGE; }; +// Determine frontend language for dual-language pages +const determineFELanguage = (location: string, _product: ProductKey | null): LanguageKey => { + const params = new URLSearchParams(location); + const feLangParam = params.get('fe_lang') as LanguageKey; + + if (feLangParam && FE_LANGUAGES.includes(feLangParam)) { + return feLangParam; + } + + // Default to javascript + return DEFAULT_LANGUAGE; +}; + +// Determine backend language for dual-language pages +const determineBELanguage = (location: string, _product: ProductKey | null): LanguageKey => { + const params = new URLSearchParams(location); + const beLangParam = params.get('be_lang') as LanguageKey; + + if (beLangParam && BE_LANGUAGES.includes(beLangParam)) { + return beLangParam; + } + + // Default to javascript + return DEFAULT_LANGUAGE; +}; + export const LayoutProvider: React.FC> = ({ children, pageContext, @@ -65,6 +109,12 @@ export const LayoutProvider: React.FC -To follow this guide, you need: -- Node.js 20 or higher + +The client code requires Node.js 20 or higher. + + +The client code requires Xcode 15 or higher. + + +The client code requires Java 11 or higher. + + + +The agent code requires Node.js 20 or higher. + + +The agent code requires Python 3.8 or higher. + + +The agent code requires Java 11 or higher. + + +You also need: - An Anthropic API key - An Ably API key Useful links: - [Anthropic API documentation](https://docs.anthropic.com/en/api) + - [Ably JavaScript SDK getting started](/docs/getting-started/javascript) - -Create a new NPM package, which will contain the publisher and subscriber code: + + +- [Ably Swift SDK getting started](/docs/getting-started/swift) + + +- [Ably Python SDK getting started](/docs/getting-started/python) + + +- [Ably Java SDK getting started](/docs/getting-started/java) + + +### Agent setup + + +Create a new npm package for the agent (publisher) code: ```shell -mkdir ably-anthropic-example && cd ably-anthropic-example +mkdir ably-anthropic-agent && cd ably-anthropic-agent npm init -y +npm install @anthropic-ai/sdk ably ``` + -Install the required packages using NPM: + +Create a new directory and install the required packages: ```shell -npm install @anthropic-ai/sdk@^0.71 ably@^2 +mkdir ably-anthropic-agent && cd ably-anthropic-agent +pip install anthropic ably ``` + - + +Create a new Maven project and add the following dependencies to your `pom.xml`: + + +```xml + + + com.anthropic + anthropic-java + 1.0.0 + + + io.ably + ably-java + 1.2.46 + + +``` + + -Export your Anthropic API key to the environment, which will be used later in the guide by the Anthropic SDK: +Export your Anthropic API key to the environment: ```shell @@ -52,6 +107,58 @@ export ANTHROPIC_API_KEY="your_api_key_here" ``` +### Client setup + + +Create a new npm package for the client (subscriber) code, or use the same project as the agent if both are JavaScript: + + +```shell +mkdir ably-anthropic-client && cd ably-anthropic-client +npm init -y +npm install ably +``` + + + + +Add the Ably SDK to your iOS or macOS project using Swift Package Manager. In Xcode, go to File > Add Package Dependencies and add: + + +```text +https://github.com/ably/ably-cocoa +``` + + +Or add it to your `Package.swift`: + + +```fe_swift +dependencies: [ + .package(url: "https://github.com/ably/ably-cocoa", from: "1.2.0") +] +``` + + + + +Add the Ably Java SDK to your `pom.xml`: + + +```xml + + io.ably + ably-java + 1.2.46 + +``` + + + + + ## Step 1: Enable message appends Message append functionality requires "Message annotations, updates, deletes and appends" to be enabled in a [channel rule](/docs/channels#rules) associated with the channel. @@ -79,10 +186,18 @@ The `ai:` namespace is just a naming convention used in this guide. There's noth Initialize an Anthropic client and use the [Messages API](https://docs.anthropic.com/en/api/messages) to stream model output as a series of events. -Create a new file `publisher.mjs` with the following contents: + +In your `ably-anthropic-agent` directory, create a new file `publisher.mjs` with the following contents: + + +In your `ably-anthropic-agent` directory, create a new file `publisher.py` with the following contents: + + +In your agent project, create a new file `Publisher.java` with the following contents: + -```javascript +```be_javascript import Anthropic from '@anthropic-ai/sdk'; // Initialize Anthropic client @@ -112,6 +227,67 @@ async function streamAnthropicResponse(prompt) { // Usage example streamAnthropicResponse("Tell me a short joke"); ``` + +```be_python +import anthropic + +# Initialize Anthropic client +client = anthropic.Anthropic() + +# Process each streaming event +def process_event(event): + print(event) + # This function is updated in the next sections + +# Create streaming response from Anthropic +def stream_anthropic_response(prompt: str): + with client.messages.stream( + model="claude-sonnet-4-5", + max_tokens=1024, + messages=[{"role": "user", "content": prompt}], + ) as stream: + for event in stream: + process_event(event) + +# Usage example +stream_anthropic_response("Tell me a short joke") +``` + +```be_java +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; +import com.anthropic.core.http.StreamResponse; +import com.anthropic.models.messages.*; + +public class Publisher { + // Initialize Anthropic client + private static final AnthropicClient client = AnthropicOkHttpClient.fromEnv(); + + // Process each streaming event + private static void processEvent(RawMessageStreamEvent event) { + System.out.println(event); + // This method is updated in the next sections + } + + // Create streaming response from Anthropic + public static void streamAnthropicResponse(String prompt) { + MessageCreateParams params = MessageCreateParams.builder() + .model(Model.CLAUDE_SONNET_4_5) + .maxTokens(1024) + .addUserMessage(prompt) + .build(); + + try (StreamResponse stream = + client.messages().createStreaming(params)) { + stream.stream().forEach(Publisher::processEvent); + } + } + + public static void main(String[] args) { + streamAnthropicResponse("Tell me a short joke"); + } +} +``` ### Understand Anthropic streaming events @@ -168,10 +344,10 @@ Each AI response is stored as a single Ably message that grows as tokens are app ### Initialize the Ably client -Add the Ably client initialization to your `publisher.mjs` file: +Add the Ably client initialization to your publisher file: -```javascript +```be_javascript import Ably from 'ably'; // Initialize Ably Realtime client @@ -183,6 +359,30 @@ const realtime = new Ably.Realtime({ // Create a channel for publishing streamed AI responses const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}'); ``` + +```be_python +from ably import AblyRealtime + +# Initialize Ably Realtime client +realtime = AblyRealtime(key='{{API_KEY}}', echo_messages=False) + +# Create a channel for publishing streamed AI responses +channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}') +``` + +```be_java +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.types.ClientOptions; + +// Initialize Ably Realtime client +ClientOptions options = new ClientOptions("{{API_KEY}}"); +options.echoMessages = false; +AblyRealtime realtime = new AblyRealtime(options); + +// Create a channel for publishing streamed AI responses +Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); +``` The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency. @@ -199,10 +399,10 @@ When a new response begins, publish an initial message to create it. Ably assign This implementation assumes each response contains a single text content block. It filters out thinking tokens and other non-text content blocks. For production use cases with multiple content blocks or concurrent responses, consider tracking state per message ID and content block index. -Update your `publisher.mjs` file to publish the initial message and append tokens: +Update your publisher file to publish the initial message and append tokens: -```javascript +```be_javascript // Track state across events let msgSerial = null; let textBlockIndex = null; @@ -244,6 +444,83 @@ async function processEvent(event) { } } ``` + +```be_python +# Track state across events +msg_serial = None +text_block_index = None + +# Process each streaming event and publish to Ably +async def process_event(event): + global msg_serial, text_block_index + + if event.type == 'message_start': + # Publish initial empty message when response starts + result = await channel.publish('response', data='') + + # Capture the message serial for appending tokens + msg_serial = result.serials[0] + + elif event.type == 'content_block_start': + # Capture text block index when a text content block is added + if event.content_block.type == 'text': + text_block_index = event.index + + elif event.type == 'content_block_delta': + # Append tokens from text deltas only + if (event.index == text_block_index and + hasattr(event.delta, 'text') and + msg_serial): + channel.append_message(serial=msg_serial, data=event.delta.text) + + elif event.type == 'message_stop': + print('Stream completed!') +``` + +```be_java +// Track state across events +private static String msgSerial = null; +private static Long textBlockIndex = null; + +// Process each streaming event and publish to Ably +private static void processEvent(RawMessageStreamEvent event) throws AblyException { + if (event.isMessageStart()) { + // Publish initial empty message when response starts + io.ably.lib.types.Message message = new io.ably.lib.types.Message("response", ""); + CompletionListener listener = new CompletionListener() { + @Override + public void onSuccess() {} + @Override + public void onError(ErrorInfo reason) {} + }; + channel.publish(message, listener); + + // Capture the message serial for appending tokens + // Note: In production, use the callback to get the serial + msgSerial = message.serial; + + } else if (event.isContentBlockStart()) { + // Capture text block index when a text content block is added + ContentBlockStartEvent blockStart = event.asContentBlockStart(); + if (blockStart.contentBlock().isText()) { + textBlockIndex = blockStart.index(); + } + + } else if (event.isContentBlockDelta()) { + // Append tokens from text deltas only + ContentBlockDeltaEvent delta = event.asContentBlockDelta(); + if (delta.index().equals(textBlockIndex) && + delta.delta().isTextDelta() && + msgSerial != null) { + String text = delta.delta().asTextDelta().text(); + channel.appendMessage(msgSerial, text); + } + + } else if (event.isMessageStop()) { + System.out.println("Stream completed!"); + } +} +``` This implementation: @@ -262,20 +539,48 @@ Standard Ably message [size limits](/docs/platform/pricing/limits#message) apply Run the publisher to see tokens streaming to Ably: + ```shell +cd ably-anthropic-agent node publisher.mjs ``` + + + + +```shell +cd ably-anthropic-agent +python publisher.py +``` + + + + + +```shell +mvn compile exec:java -Dexec.mainClass="Publisher" +``` + + ## Step 4: Subscribe to streaming tokens Create a subscriber that receives the streaming tokens from Ably and reconstructs the response in realtime. -Create a new file `subscriber.mjs` with the following contents: + +In your `ably-anthropic-client` directory, create a new file `subscriber.mjs` with the following contents: + + +Add the following code to your iOS or macOS app: + + +In your client project, create a new file `Subscriber.java` with the following contents: + -```javascript +```fe_javascript import Ably from 'ably'; // Initialize Ably Realtime client @@ -315,6 +620,108 @@ await channel.subscribe((message) => { console.log('Subscriber ready, waiting for tokens...'); ``` + +```fe_swift +import Ably + +// Initialize Ably Realtime client +let realtime = ARTRealtime(key: "{{API_KEY}}") + +// Get the same channel used by the publisher +let channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}") + +// Track responses by message serial +var responses: [String: String] = [:] + +// Subscribe to receive messages +channel.subscribe { message in + guard let serial = message.serial else { return } + + switch message.action { + case .create: + // New response started + print("\n[Response started] \(serial)") + responses[serial] = message.data as? String ?? "" + + case .messageAppend: + // Append token to existing response + let current = responses[serial] ?? "" + let token = message.data as? String ?? "" + responses[serial] = current + token + + // Display token as it arrives + print(token, terminator: "") + + case .update: + // Replace entire response content + responses[serial] = message.data as? String ?? "" + print("\n[Response updated with full content]") + + default: + break + } +} + +print("Subscriber ready, waiting for tokens...") +``` + +```fe_java +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.Message; +import java.util.HashMap; +import java.util.Map; + +public class Subscriber { + // Track responses by message serial + private static final Map responses = new HashMap<>(); + + public static void main(String[] args) throws Exception { + // Initialize Ably Realtime client + ClientOptions options = new ClientOptions("{{API_KEY}}"); + AblyRealtime realtime = new AblyRealtime(options); + + // Get the same channel used by the publisher + Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}"); + + // Subscribe to receive messages + channel.subscribe(message -> { + String serial = message.serial; + if (serial == null) return; + + switch (message.action) { + case MESSAGE_CREATE: + // New response started + System.out.println("\n[Response started] " + serial); + responses.put(serial, message.data != null ? message.data.toString() : ""); + break; + + case MESSAGE_APPEND: + // Append token to existing response + String current = responses.getOrDefault(serial, ""); + String token = message.data != null ? message.data.toString() : ""; + responses.put(serial, current + token); + + // Display token as it arrives + System.out.print(token); + break; + + case MESSAGE_UPDATE: + // Replace entire response content + responses.put(serial, message.data != null ? message.data.toString() : ""); + System.out.println("\n[Response updated with full content]"); + break; + } + }); + + System.out.println("Subscriber ready, waiting for tokens..."); + + // Keep the application running + Thread.currentThread().join(); + } +} +``` Subscribers receive different message actions depending on when they join and how they're retrieving messages: @@ -327,11 +734,26 @@ Subscribers receive different message actions depending on when they join and ho Run the subscriber in a separate terminal: + ```shell +cd ably-anthropic-client node subscriber.mjs ``` + + + +Build and run your iOS or macOS app in Xcode. + + + + +```shell +mvn compile exec:java -Dexec.mainClass="Subscriber" +``` + + With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the Anthropic model generates them. @@ -345,18 +767,39 @@ Each subscriber receives the complete stream of tokens independently, enabling y Run a subscriber in multiple separate terminals: + ```shell # Terminal 1 -node subscriber.mjs +cd ably-anthropic-client && node subscriber.mjs # Terminal 2 -node subscriber.mjs +cd ably-anthropic-client && node subscriber.mjs # Terminal 3 -node subscriber.mjs +cd ably-anthropic-client && node subscriber.mjs ``` + + + + +```shell +# Terminal 1 +mvn compile exec:java -Dexec.mainClass="Subscriber" + +# Terminal 2 +mvn compile exec:java -Dexec.mainClass="Subscriber" + +# Terminal 3 +mvn compile exec:java -Dexec.mainClass="Subscriber" +``` + + + + +Run multiple instances of your iOS or macOS app, or run on multiple devices/simulators. + All subscribers receive the same stream of tokens in realtime. @@ -366,18 +809,50 @@ Multiple publishers can stream different responses concurrently on the same [cha To demonstrate this, run a publisher in multiple separate terminals: + ```shell # Terminal 1 -node publisher.mjs +cd ably-anthropic-agent && node publisher.mjs # Terminal 2 -node publisher.mjs +cd ably-anthropic-agent && node publisher.mjs # Terminal 3 -node publisher.mjs +cd ably-anthropic-agent && node publisher.mjs ``` + + + + +```shell +# Terminal 1 +cd ably-anthropic-agent && python publisher.py + +# Terminal 2 +cd ably-anthropic-agent && python publisher.py + +# Terminal 3 +cd ably-anthropic-agent && python publisher.py +``` + + + + + +```shell +# Terminal 1 +mvn compile exec:java -Dexec.mainClass="Publisher" + +# Terminal 2 +mvn compile exec:java -Dexec.mainClass="Publisher" + +# Terminal 3 +mvn compile exec:java -Dexec.mainClass="Publisher" +``` + + All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `serial` to correlate tokens. @@ -385,10 +860,20 @@ All running subscribers receive tokens from all responses concurrently. Each sub One key advantage of the message-per-response pattern is that each complete AI response is stored as a single message in channel history. This makes it efficient to retrieve conversation history without processing thousands of individual token messages. -Use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past and automatically receive complete responses from history. Historical messages are delivered as `message.update` events containing the complete concatenated response, which then seamlessly transition to live `message.append` events for any ongoing responses: +Use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past and automatically receive complete responses from history. Historical messages are delivered as `message.update` events containing the complete concatenated response, which then seamlessly transition to live `message.append` events for any ongoing responses. + + +Update your `subscriber.mjs` file in the `ably-anthropic-client` directory to use the `rewind` option when getting the channel: + + +Update your subscriber code to use the `rewind` option when getting the channel: + + +Update your `Subscriber.java` file to use the `rewind` option when getting the channel: + -```javascript +```fe_javascript // Use rewind to receive recent historical messages const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', { params: { rewind: '2m' } // Retrieve messages from the last 2 minutes @@ -416,6 +901,86 @@ await channel.subscribe((message) => { } }); ``` + +```fe_swift +// Use rewind to receive recent historical messages +let channelOptions = ARTRealtimeChannelOptions() +channelOptions.params = ["rewind": "2m"] // Retrieve messages from the last 2 minutes + +let channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}", options: channelOptions) + +var responses: [String: String] = [:] + +channel.subscribe { message in + guard let serial = message.serial else { return } + + switch message.action { + case .create: + responses[serial] = message.data as? String ?? "" + + case .messageAppend: + let current = responses[serial] ?? "" + let token = message.data as? String ?? "" + responses[serial] = current + token + print(token, terminator: "") + + case .update: + // Historical messages contain full concatenated response + responses[serial] = message.data as? String ?? "" + print("\n[Historical response]: \(responses[serial] ?? "")") + + default: + break + } +} +``` + +```fe_java +import io.ably.lib.realtime.AblyRealtime; +import io.ably.lib.realtime.Channel; +import io.ably.lib.types.ClientOptions; +import io.ably.lib.types.ChannelOptions; +import io.ably.lib.types.Param; +import java.util.HashMap; +import java.util.Map; + +// Use rewind to receive recent historical messages +ClientOptions clientOptions = new ClientOptions("{{API_KEY}}"); +AblyRealtime realtime = new AblyRealtime(clientOptions); + +ChannelOptions channelOptions = new ChannelOptions(); +channelOptions.params = new Param[] { + new Param("rewind", "2m") // Retrieve messages from the last 2 minutes +}; + +Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}", channelOptions); + +Map responses = new HashMap<>(); + +channel.subscribe(message -> { + String serial = message.serial; + if (serial == null) return; + + switch (message.action) { + case MESSAGE_CREATE: + responses.put(serial, message.data != null ? message.data.toString() : ""); + break; + + case MESSAGE_APPEND: + String current = responses.getOrDefault(serial, ""); + String token = message.data != null ? message.data.toString() : ""; + responses.put(serial, current + token); + System.out.print(token); + break; + + case MESSAGE_UPDATE: + // Historical messages contain full concatenated response + responses.put(serial, message.data != null ? message.data.toString() : ""); + System.out.println("\n[Historical response]: " + responses.get(serial)); + break; + } +}); +``` -Update your `publisher.mjs` file to initialize the Ably client and update the `processEvent()` function to publish events to Ably: +Update your publisher file to initialize the Ably client and update the `processEvent()` function to publish events to Ably: -```javascript +```be_javascript // Track state across events let responseId = null; @@ -228,6 +263,35 @@ function processEvent(event) { } } ``` + +```be_python +# Track state across events +response_id = None + +# Process each streaming event and publish to Ably +def process_event(event): + global response_id + + if event.type == 'message_start': + # Capture message ID when response starts + response_id = event.message.id + + # Publish start event + channel.publish('start', extras={'headers': {'responseId': response_id}}) + + elif event.type == 'content_block_delta': + # Publish tokens from text deltas only + if hasattr(event.delta, 'text'): + channel.publish( + 'token', + data=event.delta.text, + extras={'headers': {'responseId': response_id}} + ) + + elif event.type == 'message_stop': + # Publish stop event when response completes + channel.publish('stop', extras={'headers': {'responseId': response_id}}) +``` This implementation: @@ -253,10 +317,10 @@ node publisher.mjs Create a subscriber that receives the streaming events from Ably and reconstructs the response. -Create a new file `subscriber.mjs` with the following contents: +Create a new file `subscriber.mjs` (JavaScript) or `subscriber.py` (Python) with the following contents: -```javascript +```fe_javascript import Ably from 'ably'; // Initialize Ably Realtime client @@ -297,6 +361,47 @@ await channel.subscribe('stop', (message) => { console.log('Subscriber ready, waiting for tokens...'); ``` + +```fe_swift +import Ably + +// Initialize Ably Realtime client +let realtime = ARTRealtime(key: "{{API_KEY}}") + +// Get the same channel used by the publisher +let channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}") + +// Track responses by ID +var responses: [String: String] = [:] + +// Handle response start +channel.subscribe("start") { message in + guard let responseId = message.extras?.headers?["responseId"] as? String else { return } + print("\n[Response started] \(responseId)") + responses[responseId] = "" +} + +// Handle tokens +channel.subscribe("token") { message in + guard let responseId = message.extras?.headers?["responseId"] as? String, + let token = message.data as? String else { return } + + // Append token to response + responses[responseId, default: ""] += token + + // Display token as it arrives + print(token, terminator: "") +} + +// Handle response stop +channel.subscribe("stop") { message in + guard let responseId = message.extras?.headers?["responseId"] as? String else { return } + let finalText = responses[responseId] ?? "" + print("\n[Response completed] \(responseId)") +} + +print("Subscriber ready, waiting for tokens...") +``` Run the subscriber in a separate terminal: diff --git a/src/styles/global.css b/src/styles/global.css index 368539e341..3a407f6c3b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -4,7 +4,6 @@ @import '@ably/ui/reset/styles.css'; @import '@ably/ui/core/styles.css'; -@import '@ably/ui/core/CookieMessage/component.css'; @import '@ably/ui/core/Slider/component.css'; @import '@ably/ui/core/Code/component.css'; @import '@ably/ui/core/Flash/component.css'; diff --git a/yarn.lock b/yarn.lock index 6b4ac9269d..6051d4b322 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,10 +7,10 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== -"@ably/ui@17.11.4": - version "17.11.4" - resolved "https://registry.yarnpkg.com/@ably/ui/-/ui-17.11.4.tgz#a2b763b201d0bbfae51dab90ee1d8797d3772536" - integrity sha512-0d+sdGW+wiRdNDWBKqk7eea1efstku5Mwu03pN0Ej7jNJEMAh4e1rDBirJvq1MENDzGloTxF62GNbrSAXD9NVw== +"@ably/ui@17.13.1-dev.c839343a": + version "17.13.1-dev.c839343a" + resolved "https://registry.yarnpkg.com/@ably/ui/-/ui-17.13.1-dev.c839343a.tgz#ca17fa6545fd9414eed73b1cfe8813dc3984122f" + integrity sha512-d8i1kt8mULg6dq9gnJfi4ge5TcmMRMHooi/pul5SynKHpHzTsauYCPjxCkq30zIvOXJV4Cqhqc07/uy3q+5FDA== dependencies: "@heroicons/react" "^2.2.0" "@radix-ui/react-accordion" "^1.2.1" @@ -26,7 +26,7 @@ embla-carousel "^8.6.0" embla-carousel-autoplay "^8.6.0" embla-carousel-react "^8.6.0" - es-toolkit "^1.43.0" + es-toolkit "^1.44.0" highlight.js "^11.11.1" highlightjs-curl "^1.3.0" js-cookie "^3.0.5" @@ -7783,10 +7783,10 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -es-toolkit@^1.43.0: - version "1.43.0" - resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.43.0.tgz#2c278d55ffeb30421e6e73a009738ed37b10ef61" - integrity sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA== +es-toolkit@^1.44.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.44.0.tgz#b363b436b6115c3cc9cc21954c1e08ecdaa51c8c" + integrity sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg== es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.62, es5-ext@^0.10.64, es5-ext@~0.10.14, es5-ext@~0.10.2: version "0.10.64" @@ -15530,16 +15530,7 @@ string-similarity@^1.2.2: lodash.map "^4.6.0" lodash.maxby "^4.6.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==