开发者详解全栈 AI 语音笔记应用 NotesGPT 的架构和实现,已获 3.5 万访问,具有参考价值。
上周,我推出了 NotesGPT,一个全栈语音笔记应用,在过去一周内获得了 35,000 个访问者、7,000 个用户,以及超过 1,000 个 GitHub stars。它允许你录制语音笔记,使用 Whisper 进行转录,并通过 Together 使用 Mixtral 提取任务项并在任务项视图中显示。它还是完全开源的,配备了身份验证、存储、向量搜索、任务项功能,并且在移动设备上完全响应式以便于使用。
我将为你详细讲述我是如何构建它的。
这是架构的快速图示。我们将在后续深入讨论每个部分,并在进展过程中展示代码示例。
前端调用 Convex 服务器函数,这些函数与数据库通信、启动后台操作等。
以下是我使用的总体技术栈:
应用的第一部分是你导航到 notesGPT 时看到的登陆页面。登陆页面上有一张移动设备和网页版的图片,以及一个"开始使用"按钮。
用户首先看到的是这个登陆页面,它与应用的其余部分一样,使用 Next.js 构建,使用 Tailwind CSS 进行样式设计。我喜欢使用 Next.js,因为它使得快速搭建网络应用和编写 React 代码变得容易。Tailwind CSS 也很棒,因为它允许你在与 JSX 相同的文件中快速迭代网页。
当用户点击主页上的任一按钮时,他们会被定向到登录屏幕。这由 Clerk 提供支持,Clerk 是一个易于使用的身份验证解决方案,与 Convex 集成良好。Convex 是我们将用于整个后端的服务,包括云函数、数据库、存储和向量搜索。
Clerk 和 Convex 都易于设置。你可以简单地在两个服务上创建账户,安装它们的 npm 库,运行 npx convex dev 来设置你的 convex 文件夹,并创建一个 ConvexProvider.ts 文件(如下所示)来包装你的应用。
1'use client';
2
3import { ReactNode } from 'react';
4import { ConvexReactClient } from 'convex/react';
5import { ConvexProviderWithClerk } from 'convex/react-clerk';
6import { ClerkProvider, useAuth } from '@clerk/nextjs';
7
8const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
9
10export default function ConvexClientProvider({
11 children,
12}: {
13 children: ReactNode;
14}) {
15 return (
16 <ClerkProvider
17 publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!}
18 >
19 <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
20 {children}
21 </ConvexProviderWithClerk>
22 </ClerkProvider>
23 );
24}
25
查看 Convex 快速开始和 Convex Clerk 身份验证部分了解更多细节。
你可以使用 Convex 时带或不带模式。在我的情况下,我知道我的数据结构并想要定义它,所以我按下面的方式做了。这也给你一个非常好的类型安全的 API 来与你的数据库交互。我们定义两个表 — 一个 notes 表来存储所有语音笔记信息,一个 actionItems 表用于提取的任务项。我们还将定义索引以便能够快速按 userId 和 noteId 查询数据。
1import { defineSchema, defineTable } from 'convex/server';
2import { v } from 'convex/values';
3
4export default defineSchema({
5 notes: defineTable({
6 userId: v.string(),
7 audioFileId: v.string(),
8 audioFileUrl: v.string(),
9 title: v.optional(v.string()),
10 transcription: v.optional(v.string()),
11 summary: v.optional(v.string()),
12 embedding: v.optional(v.array(v.float64())),
13 generatingTranscript: v.boolean(),
14 generatingTitle: v.boolean(),
15 generatingActionItems: v.boolean(),
16 })
17 .index('by_userId', ['userId'])
18 .vectorIndex('by_embedding', {
19 vectorField: 'embedding',
20 dimensions: 768,
21 filterFields: ['userId'],
22 }),
23 actionItems: defineTable({
24 noteId: v.id('notes'),
25 userId: v.string(),
26 task: v.string(),
27 })
28 .index('by_noteId', ['noteId'])
29 .index('by_userId', ['userId']),
30});
31
现在我们已经设置了我们的后端和身份验证以及我们的模式,我们可以查看获取数据。用户登录到应用后,他们可以查看他们的仪表板,其中列出了他们录制的所有语音笔记。
为了做到这一点,我们首先在 convex 文件夹中定义一个查询,它使用身份验证来接收 userId,验证其有效性,并返回与用户的 userId 匹配的所有笔记。
1export const getNotes = queryWithUser({
2 args: {},
3 handler: async (ctx, args) => {
4 const userId = ctx.userId;
5 if (userId === undefined) {
6 return null;
7 }
8 const notes = await ctx.db
9 .query('notes')
10 .withIndex('by_userId', (q) => q.eq('userId', userId))
11 .collect();
12
13 const results = Promise.all(
14 notes.map(async (note) => {
15 const count = (
16 await ctx.db
17 .query('actionItems')
18 .withIndex('by_noteId', (q) => q.eq('noteId', note._id))
19 .collect()
20 ).length;
21 return {
22 count,
23 ...note,
24 };
25 }),
26 );
27
28 return results;
29 },
30});
31
之后,我们可以通过 Convex 提供的函数用用户的身份验证令牌调用这个 getNotes 查询,以在仪表板中显示用户的所有笔记。我们使用服务器端渲染在服务器上获取这些数据,然后将其传递给 <DashboardHomePage /> 客户端组件。这也确保了数据在客户端上保持最新。
1import { api } from '@/convex/_generated/api';
2import { preloadQuery } from 'convex/nextjs';
3import DashboardHomePage from './dashboard';
4import { getAuthToken } from '../auth';
5
6const ServerDashboardHomePage = async () => {
7 const token = await getAuthToken();
8 const preloadedNotes = await preloadQuery(api.notes.getNotes, {}, { token });
9
10 return <DashboardHomePage preloadedNotes={preloadedNotes} />;
11};
12
13export default ServerDashboardHomePage;
14
最初,用户的仪表板上不会有任何语音笔记,所以他们可以点击"录制新语音笔记"按钮来录制一个。他们会看到以下屏幕,这将允许他们录制。这是在 NotesGPT 上录制语音笔记的 UI。这将使用原生浏览器 API 录制语音笔记,将文件保存在 Convex 文件存储中,然后通过 Replicate 将其发送到 Whisper 进行转录。我们做的第一件事是在我们的 convex 文件夹中定义一个 createNote 变更,它将接收这个录制,在 Convex 数据库中保存一些信息,然后调用 whisper 动作。
1export const createNote = mutationWithUser({
2 args: {
3 storageId: v.id('_storage'),
4 },
5 handler: async (ctx, { storageId }) => {
6 const userId = ctx.userId;
7 let fileUrl = (await ctx.storage.getUrl(storageId)) as string;
8
9 const noteId = await ctx.db.insert('notes', {
10 userId,
11 audioFileId: storageId,
12 audioFileUrl: fileUrl,
13 generatingTranscript: true,
14 generatingTitle: true,
15 generatingActionItems: true,
16 });
17
18 await ctx.scheduler.runAfter(0, internal.whisper.chat, {
19 fileUrl,
20 id: noteId,
21 });
22
23 return noteId;
24 },
25});
26
whisper 动作如下所示。它使用 Replicate 作为 Whisper 的托管提供商。
1export const chat = internalAction({
2 args: {
3 fileUrl: v.string(),
4 id: v.id('notes'),
5 },
6 handler: async (ctx, args) => {
7 const replicateOutput = (await replicate.run(
8 'openai/whisper:4d50797290df275329f202e48c76360b3f22b08d28c196cbc54600319435f8d2',
9 {
10 input: {
11 audio: args.fileUrl,
12 model: 'large-v3',
13 translate: false,
14 temperature: 0,
15 transcription: 'plain text',
16 suppress_tokens: '-1',
17 logprob_threshold: -1,
18 no_speech_threshold: 0.6,
19 condition_on_previous_text: true,
20 compression_ratio_threshold: 2.4,
21 temperature_increment_on_fallback: 0.2,
22 },
23 },
24 )) as whisperOutput;
25
26 const transcript = replicateOutput.transcription || 'error';
27
28 await ctx.runMutation(internal.whisper.saveTranscript, {
29 id: args.id,
30 transcript,
31 });
32 },
33});
34
同样,所有这些文件都可以在 Convex 仪表板的"文件"下看到。这是用于查看上传文件的 Convex 仪表板 UI。
用户完成录制语音笔记并通过 Whisper 完成转录后,输出随后被传递到 Together AI。同时我们显示这个加载屏幕。
这是显示标题、任务项、转录和摘要的加载指示器的页面。
我们首先定义一个我们希望输出所处的模式。然后我们将这个模式传递给我们托管在 Together.ai 上的 Mixtral 模型,并提供一个提示来识别语音笔记的摘要、转录,并基于转录生成任务项。然后我们将所有这些信息保存到 Convex 数据库。为了做到这一点,我们在 convex 文件夹中创建一个 Convex 动作。
1// convex/together.ts
2
3const NoteSchema = z.object({
4 title: z
5 .string()
6 .describe('Short descriptive title of what the voice message is about'),
7 summary: z
8 .string()
9 .describe(
10 'A short summary in the first person point of view of the person recording the voice message',
11 )
12 .max(500),
13 actionItems: z
14 .array(z.string())
15 .describe(
16 'A list of action items from the voice note, short and to the point. Make sure all action item lists are fully resolved if they are nested',
17 ),
18});
19
20export const chat = internalAction({
21 args: {
22 id: v.id('notes'),
23 transcript: v.string(),
24 },
25 handler: async (ctx, args) => {
26 const { transcript } = args;
27 const extract = await client.chat.completions.create({
28 messages: [
29 {
30 role: 'system',
31 content:
32 'The following is a transcript of a voice message. Extract a title, summary, and action items from it and answer in JSON in this format: {title: string, summary: string, actionItems: [string, string, ...]}',
33 },
34 { role: 'user', content: transcript },
35 ],
36 model: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
37 response_model: { schema: NoteSchema, name: 'SummarizeNotes' },
38 max_tokens: 1000,
39 temperature: 0.6,
40 max_retries: 3,
41 });
42 const { title, summary, actionItems } = extract;
43
44 await ctx.runMutation(internal.together.saveSummary, {
45 id: args.id,
46 summary,
47 actionItems,
48 title,
49 });
50});
51
当 Together.ai 响应时,我们得到这个最终屏幕,它让用户在左侧的转录和摘要之间切换,并在右侧查看和检查任务项。
应用的最后一部分是向量搜索。我们使用 Together.ai 嵌入来嵌入转录,使得人们能够根据转录的语义意义在仪表板中搜索。
我们通过在 convex 文件夹中创建一个 similarNotes 动作来做到这一点,该动作接收用户的搜索查询,为其生成嵌入,并找到最相似的笔记以在页面上显示。
1export const similarNotes = actionWithUser({
2 args: {
3 searchQuery: v.string(),
4 },
5 handler: async (ctx, args): Promise<SearchResult[]> => {
6 // 1. Create the embedding
7 const getEmbedding = await togetherai.embeddings.create({
8 input: [args.searchQuery.replace('/n', ' ')],
9 model: 'togethercomputer/m2-bert-80M-32k-retrieval',
10 });
11 const embedding = getEmbedding.data[0].embedding;
12
13 // 2. Then search for similar notes
14 const results = await ctx.vectorSearch('notes', 'by_embedding', {
15 vector: embedding,
16 limit: 16,
17 filter: (q) => q.eq('userId', ctx.userId), // Only search my notes.
18 });
19
20 return results.map((r) => ({
21 id: r._id,
22 score: r._score,
23 }));
24 },
25});
26
就这样,我们构建了一个生产就绪的全栈 AI 应用,配备了身份验证、数据库、存储和 API。欢迎查看 notesGPT 从你的笔记生成任务项或查看 GitHub 仓库作为参考。如果你有任何问题,给我发个私信,我会很乐意回答!
Convex 是反应式后端平台,能跟上你和你的 agents 的步伐。数据库、函数、工作流、同步、搜索、文件存储等等。全部 TypeScript,零胶水代码。