Skip to content

Commit 416bde7

Browse files
Link tooltips + AI link summaries (#3088)
Co-authored-by: Samy Pessé <[email protected]>
1 parent 580f7ad commit 416bde7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+937
-127
lines changed

bun.lock

Lines changed: 60 additions & 26 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"turbo": "^2.4.4",
88
"vercel": "^39.3.0"
99
},
10-
"packageManager": "[email protected].5",
10+
"packageManager": "[email protected].8",
1111
"overrides": {
1212
"@codemirror/state": "6.4.1",
1313
"react": "18.3.1",

packages/gitbook-v2/src/lib/data/api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ export function createDataFetcher(
179179
getUserById(userId) {
180180
return trace('getUserById', () => getUserById(input, { userId }));
181181
},
182+
183+
streamAIResponse(params) {
184+
return streamAIResponse(input, params);
185+
},
182186
};
183187
}
184188

@@ -643,6 +647,22 @@ const renderIntegrationUi = memoize(async function renderIntegrationUi(
643647
});
644648
});
645649

650+
async function* streamAIResponse(
651+
input: DataFetcherInput,
652+
params: Parameters<GitBookDataFetcher['streamAIResponse']>[0]
653+
) {
654+
const api = await apiClient(input);
655+
const res = await api.orgs.streamAiResponseInSite(params.organizationId, params.siteId, {
656+
input: params.input,
657+
output: params.output,
658+
model: params.model,
659+
});
660+
661+
for await (const event of res) {
662+
yield event;
663+
}
664+
}
665+
646666
let loggedServiceBinding = false;
647667

648668
/**

packages/gitbook-v2/src/lib/data/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,15 @@ export interface GitBookDataFetcher {
179179
integrationName: string;
180180
request: api.RenderIntegrationUI;
181181
}): Promise<DataFetcherResponse<api.ContentKitRenderOutput>>;
182+
183+
/**
184+
* Stream an AI response.
185+
*/
186+
streamAIResponse(params: {
187+
organizationId: string;
188+
siteId: string;
189+
input: api.AIMessageInput[];
190+
output: api.AIOutputFormat;
191+
model: api.AIModel;
192+
}): AsyncGenerator<api.AIStreamResponse, void, unknown>;
182193
}

packages/gitbook/package.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,11 @@
2929
"@radix-ui/react-checkbox": "^1.0.4",
3030
"@radix-ui/react-navigation-menu": "^1.2.3",
3131
"@radix-ui/react-popover": "^1.0.7",
32+
"@radix-ui/react-tooltip": "^1.1.8",
3233
"@sindresorhus/fnv1a": "^3.1.0",
3334
"@tailwindcss/container-queries": "^0.1.1",
3435
"@tailwindcss/typography": "^0.5.16",
35-
"@upstash/redis": "^1.27.1",
36-
"ai": "^4.1.46",
37-
"ajv": "^8.12.0",
36+
"ai": "^4.2.2",
3837
"assert-never": "^1.2.1",
3938
"bun-types": "^1.1.20",
4039
"classnames": "^2.5.1",
@@ -46,7 +45,7 @@
4645
"mathjax": "^3.2.2",
4746
"mdast-util-to-markdown": "^2.1.2",
4847
"memoizee": "^0.4.17",
49-
"next": "14.2.25",
48+
"next": "14.2.26",
5049
"next-themes": "^0.2.1",
5150
"nuqs": "^2.2.3",
5251
"object-hash": "^3.0.0",
@@ -68,7 +67,12 @@
6867
"tailwind-shades": "^1.1.2",
6968
"unified": "^11.0.5",
7069
"url-join": "^5.0.0",
71-
"usehooks-ts": "^3.1.0"
70+
"usehooks-ts": "^3.1.0",
71+
"zod": "^3.24.2",
72+
"zod-to-json-schema": "^3.24.5",
73+
"event-iterator": "^2.0.0",
74+
"partial-json": "^0.1.7",
75+
"zustand": "^5.0.3"
7276
},
7377
"devDependencies": {
7478
"@argos-ci/playwright": "^4.3.0",

packages/gitbook/src/app/middleware/(site)/error.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { Button } from '@/components/primitives/Button';
4-
import { t, useLanguage } from '@/intl/client';
4+
import { t, tString, useLanguage } from '@/intl/client';
55
import { tcls } from '@/lib/tailwind';
66

77
export default function ErrorPage(props: {
@@ -35,9 +35,8 @@ export default function ErrorPage(props: {
3535
}}
3636
variant="secondary"
3737
size="small"
38-
>
39-
{t(language, 'unexpected_error_retry')}
40-
</Button>
38+
label={tString(language, 'unexpected_error_retry')}
39+
/>
4140
</div>
4241
</div>
4342
</div>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use client';
2+
import { useLanguage } from '@/intl/client';
3+
import { t } from '@/intl/translate';
4+
import { Icon } from '@gitbook/icons';
5+
import { useEffect, useState } from 'react';
6+
import { useVisitedPages } from '../Insights';
7+
import { usePageContext } from '../PageContext';
8+
import { Loading } from '../primitives';
9+
import { streamLinkPageSummary } from './server-actions/streamLinkPageSummary';
10+
11+
/**
12+
* Summarise a page's content for use in a link preview
13+
*/
14+
export function AIPageLinkSummary(props: {
15+
targetSpaceId: string;
16+
targetPageId: string;
17+
linkPreview?: string;
18+
linkTitle?: string;
19+
showTrademark: boolean;
20+
}) {
21+
const { targetSpaceId, targetPageId, linkPreview, linkTitle, showTrademark = true } = props;
22+
23+
const currentPage = usePageContext();
24+
25+
const language = useLanguage();
26+
const visitedPages = useVisitedPages((state) => state.pages);
27+
const [highlight, setHighlight] = useState('');
28+
29+
useEffect(() => {
30+
let canceled = false;
31+
32+
setHighlight('');
33+
34+
(async () => {
35+
const stream = await streamLinkPageSummary({
36+
currentSpaceId: currentPage.spaceId,
37+
currentPageId: currentPage.pageId,
38+
currentPageTitle: currentPage.title,
39+
targetSpaceId,
40+
targetPageId,
41+
linkPreview,
42+
linkTitle,
43+
visitedPages,
44+
});
45+
46+
for await (const highlight of stream) {
47+
if (canceled) return;
48+
setHighlight(highlight ?? '');
49+
}
50+
})();
51+
52+
return () => {
53+
canceled = true;
54+
};
55+
}, [
56+
currentPage.pageId,
57+
currentPage.spaceId,
58+
currentPage.title,
59+
targetSpaceId,
60+
targetPageId,
61+
linkPreview,
62+
linkTitle,
63+
visitedPages,
64+
]);
65+
66+
const shimmerBlocks = [
67+
'w-[20%] [animation-delay:-1s]',
68+
'w-[35%] [animation-delay:-0.8s]',
69+
'w-[25%] [animation-delay:-0.6s]',
70+
'w-[10%] [animation-delay:-0.4s]',
71+
'w-[40%] [animation-delay:-0.2s]',
72+
'w-[30%] [animation-delay:0s]',
73+
];
74+
75+
return (
76+
<div className="flex flex-col gap-1">
77+
<div className="flex w-screen items-center gap-1 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
78+
{showTrademark ? (
79+
<Loading className="size-4" busy={!highlight || highlight.length === 0} />
80+
) : (
81+
<Icon icon="sparkle" className="size-3" />
82+
)}
83+
<h6 className="text-tint">{t(language, 'link_tooltip_ai_summary')}</h6>
84+
</div>
85+
{highlight.length > 0 ? (
86+
<p className="animate-fadeIn">{highlight}</p>
87+
) : (
88+
<div className="mt-2 flex flex-wrap gap-2">
89+
{shimmerBlocks.map((block, index) => (
90+
<div
91+
key={`${index}-${block}`}
92+
className={`${block} h-4 animate-pulse rounded straight-corners:rounded-none bg-tint-active`}
93+
/>
94+
))}
95+
</div>
96+
)}
97+
{highlight.length > 0 ? (
98+
<div className="animate-fadeIn text-tint-subtle text-xs">
99+
{t(language, 'link_tooltip_ai_summary_description')}
100+
</div>
101+
) : null}
102+
</div>
103+
);
104+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './AIPageLinkSummary';
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
'use server';
2+
import { type AIMessageInput, AIModel, type AIStreamResponse } from '@gitbook/api';
3+
import type { GitBookBaseContext } from '@v2/lib/context';
4+
import { EventIterator } from 'event-iterator';
5+
import type { MaybePromise } from 'p-map';
6+
import * as partialJson from 'partial-json';
7+
import type { DeepPartial } from 'ts-essentials';
8+
import type { z } from 'zod';
9+
import { zodToJsonSchema } from 'zod-to-json-schema';
10+
11+
/**
12+
* Get the latest value from a stream and the response id.
13+
*/
14+
export async function generate<T>(
15+
promise: MaybePromise<{
16+
stream: EventIterator<T>;
17+
response: Promise<{ responseId: string }>;
18+
}>
19+
) {
20+
const input = await promise;
21+
let value: T | undefined;
22+
23+
for await (const event of input.stream) {
24+
value = event;
25+
}
26+
27+
const { responseId } = await input.response;
28+
return {
29+
responseId,
30+
value,
31+
};
32+
}
33+
34+
/**
35+
* Stream the generation of an object using the AI.
36+
*/
37+
export async function streamGenerateObject<T>(
38+
context: GitBookBaseContext,
39+
{
40+
organizationId,
41+
siteId,
42+
}: {
43+
organizationId: string;
44+
siteId: string;
45+
},
46+
{
47+
schema,
48+
messages,
49+
model = AIModel.Fast,
50+
}: {
51+
schema: z.ZodSchema<T>;
52+
messages: AIMessageInput[];
53+
model?: AIModel;
54+
previousResponseId?: string;
55+
}
56+
) {
57+
const rawStream = context.dataFetcher.streamAIResponse({
58+
organizationId,
59+
siteId,
60+
input: messages,
61+
output: {
62+
type: 'object',
63+
schema: zodToJsonSchema(schema),
64+
},
65+
model,
66+
});
67+
68+
let json = '';
69+
return parseResponse<DeepPartial<T>>(rawStream, (event) => {
70+
if (event.type === 'response_object') {
71+
json += event.jsonChunk;
72+
73+
const parsed = partialJson.parse(json, partialJson.ALL);
74+
return parsed;
75+
}
76+
});
77+
}
78+
79+
/**
80+
* Parse a stream from the API to extract the responseId.
81+
*/
82+
function parseResponse<T>(
83+
responseStream: EventIterator<AIStreamResponse>,
84+
parse: (response: AIStreamResponse) => T | undefined
85+
): {
86+
stream: EventIterator<T>;
87+
response: Promise<{ responseId: string }>;
88+
} {
89+
let resolveResponse: (value: { responseId: string }) => void;
90+
const response = new Promise<{ responseId: string }>((resolve) => {
91+
resolveResponse = resolve;
92+
});
93+
94+
const stream = new EventIterator<T>((queue) => {
95+
(async () => {
96+
let foundResponse = false;
97+
98+
for await (const event of responseStream) {
99+
if (event.type === 'response_finish') {
100+
foundResponse = true;
101+
resolveResponse({ responseId: event.responseId });
102+
} else {
103+
const parsed = parse(event);
104+
if (parsed !== undefined) {
105+
queue.push(parsed);
106+
}
107+
}
108+
}
109+
110+
if (!foundResponse) {
111+
throw new Error('No response found');
112+
}
113+
})().then(
114+
() => {
115+
queue.stop();
116+
},
117+
(error) => {
118+
queue.fail(error);
119+
}
120+
);
121+
});
122+
123+
return { stream, response };
124+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './streamLinkPageSummary';

0 commit comments

Comments
 (0)