Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,21 @@ describe('useAskAi', () => {
expect(result.current.state.status).toBe('error')
})

it('sends getHeaders on the AG-UI request', async () => {
const answer = { kind: 'grounded', answer: 'A.', sources: [] }
const fetchMock = stubAguiFetch(aguiRun({ middle: structuredDeltas(answer), result: answer }))

const { result } = renderHook(() =>
useAskAi({ getHeaders: () => ({ Authorization: 'Bearer widget-token' }) })
)
await act(async () => {
await result.current.ask('q')
})

const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer widget-token')
})

it('reset returns the hook to idle', async () => {
const answer = { kind: 'grounded', answer: 'A.', sources: [{ articleId: 'kb_article_1' }] }
stubAguiFetch(
Expand Down
39 changes: 28 additions & 11 deletions apps/web/src/components/help-center/ask-ai.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,16 @@ export type AskAiSourceMeta = KbAskSourceMeta
* Whether Ask AI can be offered: the flag is on AND a model is configured.
* Backed by the kb-ask capability probe (404 when flags are off).
*/
export function useAskAiAvailable(enabled = true): boolean {
export function useAskAiAvailable(
enabled = true,
options?: { getHeaders?: () => HeadersInit; sessionVersion?: number }
): boolean {
const query = useQuery({
queryKey: ['kb-ask', 'capability'],
queryKey: ['kb-ask', 'capability', options?.sessionVersion ?? 'anon'] as const,
queryFn: async () => {
const res = await fetch('/api/widget/kb-ask')
const res = await fetch('/api/widget/kb-ask', {
headers: options?.getHeaders?.(),
})
if (!res.ok) return false
const json = (await res.json()) as { data?: { enabled?: boolean } }
return json.data?.enabled === true
Expand Down Expand Up @@ -100,13 +105,10 @@ function toCitations(sources: AskAiSourceMeta[]): ConversationMessageCitation[]

const KB_ASK_URL = '/api/widget/kb-ask'

/** Non-2xx widget envelopes (rate limits, flag gates, budget) become a
* synthetic RUN_ERROR SSE frame. Throwing from fetchClient is wrong on AI
* 0.52+: the adapter wraps any rejection as StreamReadError. */
const askAiFetch = aguiFetchClient()

/** Drive one Ask AI question at a time; re-asking aborts the previous run. */
export function useAskAi() {
export function useAskAi(options?: { getHeaders?: () => HeadersInit }) {
const getHeadersRef = useRef(options?.getHeaders)
getHeadersRef.current = options?.getHeaders
const [state, setState] = useState<AskAiState>(IDLE_STATE)
const clientRef = useRef<ChatClient | null>(null)

Expand Down Expand Up @@ -179,7 +181,10 @@ export function useAskAi() {
}

const client = new ChatClient({
connection: fetchServerSentEvents(KB_ASK_URL, () => ({ fetchClient: askAiFetch })),
connection: fetchServerSentEvents(KB_ASK_URL, () => ({
// Non-2xx widget envelopes become a synthetic RUN_ERROR SSE frame.
fetchClient: aguiFetchClient(() => getHeadersRef.current?.()),
})),
onChunk: (rawChunk: StreamChunk) => {
const chunk = rawChunk as {
type: string
Expand Down Expand Up @@ -269,6 +274,10 @@ export interface AskAiSearchControllerOptions {
/** Surface hook fired when the answer panel is dismissed (e.g. reopen the
* dropdown for the current query). */
onDismiss?: () => void
/** Widget Bearer (or empty on the portal, which uses cookies). */
getHeaders?: () => HeadersInit
/** Widget session — reset an open answer when identity changes. */
sessionVersion?: number
}

/**
Expand All @@ -287,8 +296,10 @@ export function useAskAiSearchController({
onClearQuery,
onAsk,
onDismiss,
getHeaders,
sessionVersion,
}: AskAiSearchControllerOptions) {
const { state: askAiState, ask: askAi, reset: resetAskAi } = useAskAi()
const { state: askAiState, ask: askAi, reset: resetAskAi } = useAskAi({ getHeaders })
// Keyboard selection over [ask-ai row, ...results]; -1 = nothing selected.
const [selectedIndex, setSelectedIndex] = useState(-1)

Expand All @@ -303,6 +314,12 @@ export function useAskAiSearchController({
setSelectedIndex(-1)
}, [query, resetAskAi])

// Logout / identify must not leave the previous visitor's cited titles up.
useEffect(() => {
resetAskAi()
setSelectedIndex(-1)
}, [sessionVersion, resetAskAi])

const triggerAsk = useCallback(() => {
if (!hasAskRow) return
setSelectedIndex(-1)
Expand Down
29 changes: 25 additions & 4 deletions apps/web/src/components/help-center/use-kb-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function useKbSearch({
query,
limit,
locale,
sessionVersion,
getHeaders,
onResults,
}: {
query: string
Expand All @@ -29,6 +31,10 @@ export function useKbSearch({
* (domains/languages §2); an unrecognized/not-enabled locale falls back
* to default server-side. */
locale?: string
/** Widget session — included in the cache key and clears hits on change. */
sessionVersion?: number
/** Widget Bearer (or empty on the portal, which uses cookies). */
getHeaders?: () => HeadersInit
/** Fired with the articles of each completed search (cache hits included);
* not fired when the query is blank. */
onResults?: (articles: KbSearchArticle[]) => void
Expand All @@ -40,15 +46,19 @@ export function useKbSearch({
// Latest callback without retriggering the debounce effect.
const onResultsRef = useRef(onResults)
onResultsRef.current = onResults
const getHeadersRef = useRef(getHeaders)
getHeadersRef.current = getHeaders
const sessionVersionRef = useRef(sessionVersion)
sessionVersionRef.current = sessionVersion

const doSearch = useCallback(
async (q: string, loc: string | undefined) => {
async (q: string, loc: string | undefined, version: number | undefined) => {
if (!q.trim()) {
setResults([])
return
}

const cacheKey = `${loc ?? ''}:${q}`
const cacheKey = `${version ?? ''}:${loc ?? ''}:${q}`
const cached = cacheRef.current.get(cacheKey)
if (cached) {
setResults(cached)
Expand All @@ -71,7 +81,10 @@ export function useKbSearch({
if (loc) params.set('locale', loc)
const res = await fetch(`/api/widget/kb-search?${params.toString()}`, {
signal: controller.signal,
headers: getHeadersRef.current?.(),
})
if (!res.ok) return
if (version !== sessionVersionRef.current) return
const data = await res.json()
const articles: KbSearchArticle[] = data.data?.articles ?? []
cacheRef.current.set(cacheKey, articles)
Expand All @@ -87,9 +100,17 @@ export function useKbSearch({
)

useEffect(() => {
const timer = setTimeout(() => void doSearch(query, locale), DEBOUNCE_MS)
abortRef.current?.abort()
abortRef.current = null
cacheRef.current.clear()
setResults([])
setIsSearching(false)
}, [sessionVersion])
Comment thread
mortondev marked this conversation as resolved.

useEffect(() => {
const timer = setTimeout(() => void doSearch(query, locale, sessionVersion), DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [query, locale, doSearch])
}, [query, locale, sessionVersion, doSearch])

return { results, isSearching }
}
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,9 @@ export function VisitorConversationThread({
const [helpResults, setHelpResults] = useState<Array<{ slug: string; title: string }>>([])
const helpSearchFn = helpSearch?.search
const messageText = composer.text
useEffect(() => {
setHelpResults([])
}, [helpSearchFn, sessionVersion])
useEffect(() => {
if (!helpSearchFn || conversationId || messages.length > 0) {
setHelpResults([])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { buildPortalUrl } from '../build-portal-url'
import { appendWidgetOtt, buildPortalUrl } from '../build-portal-url'

describe('buildPortalUrl', () => {
const baseUrl = 'https://feedback.example.com'
Expand Down Expand Up @@ -63,3 +63,23 @@ describe('buildPortalUrl', () => {
expect(url).toContain('?ott=token%2Bwith%2Fspecial%3Dchars')
})
})

describe('appendWidgetOtt', () => {
it('appends ott when identified', () => {
expect(
appendWidgetOtt('https://feedback.example.com/hc/articles/getting-started/faq', true, 'ott-1')
).toBe('https://feedback.example.com/hc/articles/getting-started/faq?ott=ott-1')
})

it('leaves the URL unchanged for anonymous visitors', () => {
expect(
appendWidgetOtt('https://feedback.example.com/changelog/changelog_1', false, 'ott-1')
).toBe('https://feedback.example.com/changelog/changelog_1')
})

it('leaves the URL unchanged when OTT generation returned null', () => {
expect(appendWidgetOtt('https://feedback.example.com/changelog/changelog_1', true, null)).toBe(
'https://feedback.example.com/changelog/changelog_1'
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { INITIAL_SESSION_VERSION } from '@/lib/client/hooks/use-widget-vote'
import { shouldClearUnavailableChangelogCategory } from '../widget-changelog-query'

describe('shouldClearUnavailableChangelogCategory', () => {
const ready = { sessionVersion: 1, listReady: true, stillLooking: false }

it('clears a filter the current session feed no longer contains', () => {
expect(shouldClearUnavailableChangelogCategory('secret', [{ id: 'public' }], ready)).toBe(true)
expect(shouldClearUnavailableChangelogCategory('public', [{ id: 'public' }], ready)).toBe(false)
expect(shouldClearUnavailableChangelogCategory(null, [{ id: 'public' }], ready)).toBe(false)
})

it('waits until the feed is in and lookahead has finished', () => {
expect(
shouldClearUnavailableChangelogCategory('secret', [], {
...ready,
listReady: false,
})
).toBe(false)
expect(
shouldClearUnavailableChangelogCategory('secret', [], {
...ready,
stillLooking: true,
})
).toBe(false)
})

it('does not bounce on the anonymous first paint', () => {
expect(
shouldClearUnavailableChangelogCategory('secret', [], {
...ready,
sessionVersion: INITIAL_SESSION_VERSION,
})
).toBe(false)
})
})
Loading
Loading