Skip to content

Commit 883a76d

Browse files
author
Filip
committed
feat: summarize links using ai
1 parent b153957 commit 883a76d

File tree

12 files changed

+1233
-280
lines changed

12 files changed

+1233
-280
lines changed

apps/backend/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
".": "./src/index.ts"
1818
},
1919
"dependencies": {
20-
"better-auth": "catalog:",
21-
"hono": "catalog:",
20+
"@ai-sdk/groq": "^2.0.24",
2221
"@hono/zod-validator": "^0.7.3",
2322
"@prisma/adapter-d1": "^6.16.3",
2423
"@prisma/client": "6.16.3",
24+
"ai": "^5.0.76",
25+
"better-auth": "catalog:",
26+
"hono": "catalog:",
2527
"pino": "^10.0.0",
2628
"zod": "^4.1.11"
2729
},
@@ -30,7 +32,7 @@
3032
"typescript": "catalog:",
3133
"prisma": "^6.16.3",
3234
"tsx": "^4.20.6",
33-
"wrangler": "^4.42.2",
35+
"wrangler": "^4.45.1",
3436
"oxlint": "catalog:",
3537
"oxlint-tsgolint": "catalog:"
3638
}

apps/backend/src/links.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { createGroq } from "@ai-sdk/groq";
12
import { zValidator } from "@hono/zod-validator";
3+
import { generateText } from "ai";
24
import z from "zod";
35
import { appFactory } from "./app-factory";
46

@@ -119,4 +121,57 @@ export const linksApp = appFactory
119121

120122
return c.json(link);
121123
},
124+
)
125+
.post(
126+
"/links/:id/ai-summary",
127+
zValidator("param", z.object({ id: z.string().max(1000) })),
128+
async (c) => {
129+
const user = c.get("user");
130+
if (!user) {
131+
return c.json({ error: "Unauthorized" }, 401);
132+
}
133+
134+
const { id } = c.req.valid("param");
135+
136+
const link = await c.get("prisma").link.findUnique({ where: { id } });
137+
if (!link) return c.json({ error: "Link not found" }, 404);
138+
139+
if (link.userId !== user.id) {
140+
return c.json({ error: "You are not the owner of this link" }, 401);
141+
}
142+
143+
const groq = createGroq({
144+
apiKey: c.env.GROQ_API_KEY,
145+
});
146+
147+
const { text } = await generateText({
148+
model: groq("openai/gpt-oss-20b"),
149+
prompt: `
150+
Summarize the content at this URL: ${link.url}.
151+
152+
Output format: Markdown unordered list. Each list item must follow this exact format: **Title** - Description
153+
154+
- Provide bullet points that capture the main ideas, key takeaways, and any clear conclusions.
155+
- Keep each bullet concise.
156+
- Use plain, easy-to-understand language.
157+
- If the page is behind a paywall or unreachable, say so and extract whatever is visible.
158+
- No long quotes; no extra commentary or analysis unless it's an explicit main takeaway.
159+
- Output only Markdown.
160+
`,
161+
tools: {
162+
browser_search: groq.tools.browserSearch({}),
163+
},
164+
toolChoice: "required",
165+
providerOptions: {
166+
groq: {
167+
reasoningEffort: "low",
168+
},
169+
},
170+
topP: 1,
171+
temperature: 1,
172+
maxOutputTokens: 8192,
173+
});
174+
175+
return c.json({ data: text });
176+
},
122177
);

apps/backend/worker-configuration.d.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable */
2-
// Generated by Wrangler by running `wrangler types --env-interface CloudflareBindings` (hash: c7ca6cd5dbf1ee032486719b5ba2f133)
3-
// Runtime types generated with workerd@1.20251008.0 2025-10-13
2+
// Generated by Wrangler by running `wrangler types --env-interface CloudflareBindings` (hash: bf4909deb35eddfba8904f967119eebc)
3+
// Runtime types generated with workerd@1.20251011.0 2025-10-28 nodejs_compat
44
declare namespace Cloudflare {
55
interface GlobalProps {
66
mainModule: typeof import("./src/index");
@@ -9,10 +9,28 @@ declare namespace Cloudflare {
99
BETTER_AUTH_SECRET: string;
1010
BETTER_AUTH_URL: string;
1111
CORS_ORIGINS: string;
12+
GROQ_API_KEY: string;
1213
D1_DATABASE: D1Database;
1314
}
1415
}
1516
interface CloudflareBindings extends Cloudflare.Env {}
17+
type StringifyValues<EnvType extends Record<string, unknown>> = {
18+
[Binding in keyof EnvType]: EnvType[Binding] extends string
19+
? EnvType[Binding]
20+
: string;
21+
};
22+
declare namespace NodeJS {
23+
interface ProcessEnv
24+
extends StringifyValues<
25+
Pick<
26+
Cloudflare.Env,
27+
| "BETTER_AUTH_SECRET"
28+
| "BETTER_AUTH_URL"
29+
| "CORS_ORIGINS"
30+
| "GROQ_API_KEY"
31+
>
32+
> {}
33+
}
1634

1735
// Begin runtime types
1836
/*! *****************************************************************************
@@ -6762,13 +6780,6 @@ type AiOptions = {
67626780
prefix?: string;
67636781
extraHeaders?: object;
67646782
};
6765-
type ConversionResponse = {
6766-
name: string;
6767-
mimeType: string;
6768-
format: "markdown";
6769-
tokens: number;
6770-
data: string;
6771-
};
67726783
type AiModelsSearchParams = {
67736784
author?: string;
67746785
hide_experimental?: boolean;
@@ -6825,6 +6836,7 @@ declare abstract class Ai<AiModelList extends AiModelListType = AiModels> {
68256836
: AiModelList[Name]["postProcessedOutputs"]
68266837
>;
68276838
models(params?: AiModelsSearchParams): Promise<AiModelsSearchObject[]>;
6839+
toMarkdown(): ToMarkdownService;
68286840
toMarkdown(
68296841
files: {
68306842
name: string;
@@ -9049,6 +9061,47 @@ declare module "cloudflare:sockets" {
90499061
): Socket;
90509062
export { _connect as connect };
90519063
}
9064+
type ConversionResponse = {
9065+
name: string;
9066+
mimeType: string;
9067+
} & (
9068+
| {
9069+
format: "markdown";
9070+
tokens: number;
9071+
data: string;
9072+
}
9073+
| {
9074+
format: "error";
9075+
error: string;
9076+
}
9077+
);
9078+
type SupportedFileFormat = {
9079+
mimeType: string;
9080+
extension: string;
9081+
};
9082+
declare abstract class ToMarkdownService {
9083+
transform(
9084+
files: {
9085+
name: string;
9086+
blob: Blob;
9087+
}[],
9088+
options?: {
9089+
gateway?: GatewayOptions;
9090+
extraHeaders?: object;
9091+
},
9092+
): Promise<ConversionResponse[]>;
9093+
transform(
9094+
files: {
9095+
name: string;
9096+
blob: Blob;
9097+
},
9098+
options?: {
9099+
gateway?: GatewayOptions;
9100+
extraHeaders?: object;
9101+
},
9102+
): Promise<ConversionResponse>;
9103+
supported(): Promise<SupportedFileFormat[]>;
9104+
}
90529105
declare namespace TailStream {
90539106
interface Header {
90549107
readonly name: string;

apps/backend/wrangler.jsonc

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "node_modules/wrangler/config-schema.json",
33
"name": "random-links",
44
"main": "src/index.ts",
5-
"compatibility_date": "2025-10-13",
5+
"compatibility_date": "2025-10-28",
66
"d1_databases": [
77
{
88
"binding": "D1_DATABASE",
@@ -20,7 +20,8 @@
2020
"head_sampling_rate": 1,
2121
"invocation_logs": true
2222
}
23-
}
23+
},
24+
"compatibility_flags": ["nodejs_compat"]
2425
// "vars": {
2526
// "MY_VAR": "my-variable"
2627
// },

apps/web-app/app/app.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@import "tailwindcss";
22
@plugin './ui/heroui.config.ts';
3+
@plugin "@tailwindcss/typography";
34

45
@source '../../../node_modules/@heroui/theme/**/*.{js,ts,jsx,tsx}';
56
@custom-variant dark (&:is(.dark *));
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
Button,
3+
Modal,
4+
ModalBody,
5+
ModalContent,
6+
ModalHeader,
7+
Tooltip,
8+
} from "@heroui/react";
9+
import { SparklesIcon } from "lucide-react";
10+
import ReactMarkdown from "react-markdown";
11+
12+
export function GenerateAiSummaryButton({
13+
isPending,
14+
type,
15+
onPress,
16+
}: {
17+
isPending: boolean;
18+
type: "button";
19+
onPress: () => void;
20+
}) {
21+
return (
22+
<Tooltip closeDelay={150} content="AI Summary">
23+
<Button
24+
color="secondary"
25+
isIconOnly
26+
isLoading={isPending}
27+
type={type}
28+
onPress={onPress}
29+
>
30+
<SparklesIcon size={16} />
31+
<span className="sr-only">AI Summary</span>
32+
</Button>
33+
</Tooltip>
34+
);
35+
}
36+
37+
export function AiSummaryModal({
38+
isOpen,
39+
setIsOpen,
40+
children,
41+
linkTitle,
42+
}: {
43+
isOpen: boolean;
44+
setIsOpen: (isOpen: boolean) => void;
45+
children: string;
46+
linkTitle: string;
47+
}) {
48+
return (
49+
<Modal isOpen={isOpen} onOpenChange={setIsOpen}>
50+
<ModalContent>
51+
<ModalHeader>
52+
<span className="line-clamp-2 mr-2">AI Summary for {linkTitle}</span>
53+
</ModalHeader>
54+
<ModalBody>
55+
<ReactMarkdown>{children}</ReactMarkdown>
56+
</ModalBody>
57+
</ModalContent>
58+
</Modal>
59+
);
60+
}

apps/web-app/app/components/link-card.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@ import {
99
} from "@heroui/react";
1010
import { Edit, ExternalLink, Trash2 } from "lucide-react";
1111
import type { Link as LinkItem } from "../data/types";
12+
import { GenerateAiSummaryButton } from "./ai-summary";
1213

1314
interface LinkCardProps {
1415
link: LinkItem;
1516
onEdit: (link: LinkItem) => void;
1617
onDelete: (link: LinkItem) => void;
18+
aiSummary: {
19+
handler: () => void;
20+
isPending: boolean;
21+
};
1722
}
1823

19-
export function LinkCard({ link, onEdit, onDelete }: LinkCardProps) {
24+
export function LinkCard({ link, onEdit, onDelete, aiSummary }: LinkCardProps) {
2025
const linkDomain = new URL(link.url).hostname;
2126

2227
return (
@@ -28,6 +33,11 @@ export function LinkCard({ link, onEdit, onDelete }: LinkCardProps) {
2833
</div>
2934

3035
<ButtonGroup size="sm" variant="flat">
36+
<GenerateAiSummaryButton
37+
type="button"
38+
onPress={aiSummary.handler}
39+
isPending={aiSummary.isPending}
40+
/>
3141
<Tooltip closeDelay={150} content="Edit">
3242
<Button isIconOnly onPress={() => onEdit(link)}>
3343
<Edit size={16} />

apps/web-app/app/data/links.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,16 @@ export const deleteLink = async (id: string): Promise<Result<{}, string>> => {
6161

6262
return ok({});
6363
};
64+
65+
export const linkAiSummary = async (
66+
id: string,
67+
): Promise<Result<{ data: string }, string>> => {
68+
const res = await apiClient.links[":id"]["ai-summary"].$post({
69+
param: { id },
70+
});
71+
const json = await res.json();
72+
73+
if ("error" in json) return err(json.error);
74+
75+
return ok({ data: json.data });
76+
};
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { err, ok, type Result } from "@repo/type-safe-errors";
2+
import { useFetcher } from "react-router";
3+
import { linkAiSummary } from "~/data/links";
4+
import type { Link } from "~/data/types";
5+
import type { Route } from "./+types/ai-summary.$linkId";
6+
7+
type ActionData = Result<
8+
{ data: string; linkId: string; linkName: string },
9+
{ message: string }
10+
>;
11+
12+
export async function clientAction({
13+
request,
14+
}: Route.ClientActionArgs): Promise<ActionData> {
15+
const formData = await request.formData();
16+
const linkId = formData.get("linkId") as string;
17+
const linkName = formData.get("linkName") as string;
18+
19+
const result = await linkAiSummary(linkId);
20+
if (!result.ok) {
21+
return err({ message: result.error ?? "Failed to generate AI summary" });
22+
}
23+
24+
return ok({ data: result.value.data, linkId: linkId, linkName: linkName });
25+
}
26+
27+
export function useGenerateAiSummary() {
28+
const fetcher = useFetcher<ActionData>();
29+
return {
30+
isPending: (linkId: string) =>
31+
fetcher.state !== "idle" &&
32+
fetcher.formAction === `/ai-summary/${linkId}`,
33+
data: fetcher.data,
34+
submit: (link: Link) =>
35+
fetcher.submit(
36+
{ linkId: link.id, linkName: link.name },
37+
{ method: "post", action: `/ai-summary/${link.id}` },
38+
),
39+
reset: () => fetcher.unstable_reset(),
40+
};
41+
}

0 commit comments

Comments
 (0)