Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion examples/react/getting-started/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { liteClient as algoliasearch } from 'algoliasearch/lite';
import { Hit } from 'instantsearch.js';
import React from 'react';
import {
Configure,
Expand All @@ -12,12 +11,15 @@ import {
TrendingItems,
Carousel,
Chat,
ChatPageSuggestions,
FilterSuggestions,
CurrentRefinements,
} from 'react-instantsearch';

import { Panel } from './Panel';

import type { Hit } from 'instantsearch.js';

import 'instantsearch.css/themes/satellite.css';

import './App.css';
Expand Down Expand Up @@ -76,6 +78,12 @@ export function App() {
headerComponent={false}
/>
</Panel>
<Panel header="Prompt pills (POC)">
<ChatPageSuggestions
maxSuggestions={4}
transport={{ api: '/api/chat-page-suggestions?delay=3000' }}
/>
</Panel>
<Hits hitComponent={HitComponent} />

<div className="pagination">
Expand Down
77 changes: 76 additions & 1 deletion examples/react/getting-started/vite.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,83 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import commonjs from 'vite-plugin-commonjs';

// Mocks the agent-studio response shape `connectChatPageSuggestions` parses
// (`data.parts[1].text` = JSON-stringified string array). Reactive to query
// and first-hit category so refinements visibly drive the pills.
function chatPageSuggestionsMockPlugin() {
return {
name: 'chat-page-suggestions-mock',
configureServer(server) {
server.middlewares.use('/api/chat-page-suggestions', async (req, res, next) => {
if (req.method !== 'POST') {
next();
return;
}
// Tune via `?delay=N` (ms). Default 800ms so the loading skeleton is
// clearly visible on every refetch (initial mount + each refinement
// change).
const url = new URL(req.url ?? '', 'http://localhost');
const delayParam = Number(url.searchParams.get('delay'));
const delayMs =
Number.isFinite(delayParam) && delayParam >= 0 ? delayParam : 800;
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString('utf8');
let input = {};
try {
const body = JSON.parse(raw);
const text = body?.messages?.[0]?.parts?.[0]?.text;
if (typeof text === 'string') input = JSON.parse(text);
} catch {
// ignore — fall through with empty input
}
const query = (input.query || '').trim();
const category = input.hitsSample?.[0]?.categories?.[0];
const max = Math.max(1, Math.min(input.maxSuggestions ?? 4, 8));

const pool = [];
if (query) {
pool.push(
`Compare the top ${query} options`,
`What should I look for in a ${query}?`,
`Recommend a ${query} under $500`,
`Show me bestsellers for "${query}"`
);
}
if (category) {
pool.push(
`What's new in ${category}?`,
`Top-rated ${category} this month`,
`Help me choose a ${category}`
);
}
pool.push(
'Help me find what I need',
'What are people buying right now?',
'Suggest something for a gift'
);
const suggestions = Array.from(new Set(pool)).slice(0, max);

await new Promise((resolve) => setTimeout(resolve, delayMs));

res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
id: `dummy-${Date.now()}`,
role: 'assistant',
parts: [
{ type: 'reasoning', text: 'mocked' },
{ type: 'text', text: JSON.stringify(suggestions) },
],
})
);
});
},
};
}

export default defineConfig({
plugins: [commonjs(), react()],
plugins: [commonjs(), react(), chatPageSuggestionsMockPlugin()],
build: {
commonjsOptions: {
requireReturnsDefault: 'preferred',
Expand Down
49 changes: 38 additions & 11 deletions examples/react/next-app-router/app/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import Link from 'next/link';
import React from 'react';
import {
Chat,
ChatPageSuggestions,
Hits,
SearchBox,
RefinementList,
DynamicWidgets,
} from 'react-instantsearch';
import { InstantSearchNext } from 'react-instantsearch-nextjs';

Expand All @@ -15,7 +16,14 @@ import { Panel } from '../components/Panel';
import { QueryId } from '../components/QueryId';
import { client } from '../lib/client';

export default function Search() {
const isServer = typeof window === 'undefined';
const PHASE = isServer ? '[SSR]' : '[CSR]';

console.log(
`${PHASE} Search.tsx module evaluated at ${new Date().toISOString()}`
);

export default function Search({ baseUrl }: { baseUrl: string }) {
return (
<InstantSearchNext
searchClient={client}
Expand All @@ -25,25 +33,44 @@ export default function Search() {
>
<div className="Container">
<div>
<DynamicWidgets fallbackComponent={FallbackComponent} />
<Panel header="brand">
<RefinementList attribute="brand" />
</Panel>
<Panel header="categories">
<RefinementList attribute="categories" />
</Panel>
</div>
<div>
<SearchBox />
<Panel header="Prompt pills (SSR test)">
{/*
Demo wiring:
- `?delay=500` on the dummy endpoint
- `ssrTimeoutMs={1500}` → SSR wins the race and bakes pills into
server HTML (curl the page and grep for one).
- On client refinement changes, the 500ms delay makes the
skeleton visible during the refetch.
Flip `delay` higher than `ssrTimeoutMs` to test the SSR-timeout
path (server HTML has no pills; client renders the skeleton
then the pills after hydration).
*/}
<ChatPageSuggestions
maxSuggestions={4}
ssrTimeoutMs={1500}
transport={{
api: `${baseUrl}/api/chat-page-suggestions?delay=500`,
}}
/>
</Panel>
<Hits hitComponent={Hit} />
</div>
</div>
<QueryId />
<Link href="/layout" id="link">
Other page
</Link>
</InstantSearchNext>
);
}

function FallbackComponent({ attribute }: { attribute: string }) {
return (
<Panel header={attribute}>
<RefinementList attribute={attribute} />
</Panel>
<Chat agentId="eedef238-5468-470d-bc37-f99fa741bd25" feedback={true} />
</InstantSearchNext>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server';

type RequestBody = {
messages?: Array<{
parts?: Array<{ type?: string; text?: string }>;
}>;
};

type AgentInput = {
query?: string;
hitsSample?: Array<{ name?: string; categories?: string[] }>;
context?: Record<string, unknown>;
maxSuggestions?: number;
};

// Returns a mocked agent-studio response in the shape the
// `connectChatPageSuggestions` connector expects:
// { parts: [<anything>, { text: '["prompt one", "prompt two", ...]' }] }
//
// Suggestions are loosely derived from the search query and the first hit's
// category so the demo feels reactive to refinements.
export async function POST(request: Request) {
const url = new URL(request.url);
const delayParam = Number(url.searchParams.get('delay'));
// Tune via `?delay=N` (ms). Default 500ms — long enough to see the client
// skeleton on refinement changes; short enough that SSR (with a generous
// `ssrTimeoutMs`) still wins the race and bakes pills into server HTML.
const delayMs = Number.isFinite(delayParam) && delayParam >= 0 ? delayParam : 500;

const body = (await request.json().catch(() => ({}))) as RequestBody;
const rawText = body.messages?.[0]?.parts?.[0]?.text ?? '{}';
const input: AgentInput =
typeof rawText === 'string'
? safeParse<AgentInput>(rawText) ?? {}
: (rawText as AgentInput);

const query = (input.query || '').trim();
const firstHit = input.hitsSample?.[0];
const category = firstHit?.categories?.[0];
const max = Math.max(1, Math.min(input.maxSuggestions ?? 4, 8));

const pool: string[] = [];
if (query) {
pool.push(
`Compare the top ${query} options`,
`What should I look for in a ${query}?`,
`Recommend a ${query} under $500`,
`Show me bestsellers for "${query}"`
);
}
if (category) {
pool.push(
`What's new in ${category}?`,
`Top-rated ${category} this month`,
`Help me choose a ${category}`
);
}
pool.push(
'Help me find what I need',
'What are people buying right now?',
'Suggest something for a gift'
);

// Dedupe and trim to the requested length.
const suggestions = Array.from(new Set(pool)).slice(0, max);

await new Promise((resolve) => setTimeout(resolve, delayMs));

return NextResponse.json({
id: `dummy-${Date.now()}`,
role: 'assistant',
parts: [
{ type: 'reasoning', text: 'mocked' },
{ type: 'text', text: JSON.stringify(suggestions) },
],
});
}

function safeParse<T>(text: string): T | null {
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
12 changes: 10 additions & 2 deletions examples/react/next-app-router/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { headers } from 'next/headers';
import React from 'react';

import { responsesCache } from '../lib/client';
Expand All @@ -6,8 +7,15 @@ import Search from './Search';

export const dynamic = 'force-dynamic';

export default function Page() {
export default async function Page() {
responsesCache.clear();

return <Search />;
// SSR-side fetches (e.g. `<ChatPageSuggestions>`'s transport) need an
// absolute URL because Node fetch can't resolve relative paths.
const h = await headers();
const host = h.get('host') ?? 'localhost:3000';
const proto = h.get('x-forwarded-proto') ?? 'http';
const baseUrl = `${proto}://${host}`;

return <Search baseUrl={baseUrl} />;
}
1 change: 1 addition & 0 deletions examples/react/next-app-router/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading