我是如何构建 NotesGPT 的——一款全栈 AI 语音笔记应用
上周,我发布了notesGPT,这是一款免费开源的语音笔记应用,截至目前,该应用拥有35,000 名访客、7,000 名用户和超过 1,000 个 GitHub 星标。它支持录制语音笔记,使用Whisper进行转录,并通过Together使用 Mixtral提取行动项并将其显示在行动项视图中。它完全开源,配备了身份验证、存储、向量搜索和行动项功能,并且在移动设备上响应迅速,易于使用。
我将向您详细讲解我是如何构建它的。
架构和技术栈
这是架构的简要示意图。我们将更深入地讨论每个部分,并展示代码示例。
以下是我使用的整体技术栈:
- 凸函数用于数据库和云函数
- 框架的Next.js App Router
- 复制Whisper 转录
- 适用于 LLM 的Mixtral和JSON 模式
- Together.ai用于推理和嵌入
- 用于存储语音笔记的凸文件存储
- 凸向量搜索用于向量搜索
- 负责用户身份验证的职员
- Tailwind CSS用于样式
登陆页面
该应用程序的第一部分是您导航到 notesGPT 时看到的登录页面。
用户首先看到的是这个落地页,它和应用的其他部分一样,都是用 Next.js 构建的,并使用 Tailwind CSS 进行样式设置。我喜欢使用 Next.js,因为它可以轻松启动 Web 应用,只需编写 React 代码即可。Tailwind CSS 也很棒,因为它允许你在与 JSX 相同的文件中快速迭代网页。
使用 Clerk 和 Convex 进行身份验证
当用户点击主页上的任意按钮时,都会被引导至登录界面。登录界面由 Clerk 提供支持,这是一款简单的身份验证解决方案,可以与 Convex 完美集成。我们将使用 Convex 来构建整个后端,包括云函数、数据库、存储和向量搜索。
Clerk 和 Convex 的设置都很简单。你只需在这两个服务上创建一个帐户,安装它们的 npm 库,运行“setup”命令npx convex dev
设置你的 Convex 文件夹,然后创建一个ConvexProvider.ts
如下所示的文件来包装你的应用即可。
'use client';
import { ReactNode } from 'react';
import { ConvexReactClient } from 'convex/react';
import { ConvexProviderWithClerk } from 'convex/react-clerk';
import { ClerkProvider, useAuth } from '@clerk/nextjs';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export default function ConvexClientProvider({
children,
}: {
children: ReactNode;
}) {
return (
<ClerkProvider
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!}
>
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithClerk>
</ClerkProvider>
);
}
请查看Convex Quickstart和Convex Clerk auth 部分以了解更多详细信息。
设置我们的模式
无论是否使用模式,您都可以使用 Convex。就我而言,我知道数据的结构,并希望对其进行定义,因此我进行了如下操作。这也为您提供了一个非常优秀的类型安全 API,方便您与数据库交互。我们将定义两个表:一个notes
用于存储所有语音笔记信息,actionItems
另一个用于存储提取的操作项。我们还将定义索引,以便能够通过userId
和 来快速查询数据noteId
。
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
notes: defineTable({
userId: v.string(),
audioFileId: v.string(),
audioFileUrl: v.string(),
title: v.optional(v.string()),
transcription: v.optional(v.string()),
summary: v.optional(v.string()),
embedding: v.optional(v.array(v.float64())),
generatingTranscript: v.boolean(),
generatingTitle: v.boolean(),
generatingActionItems: v.boolean(),
})
.index('by_userId', ['userId'])
.vectorIndex('by_embedding', {
vectorField: 'embedding',
dimensions: 768,
filterFields: ['userId'],
}),
actionItems: defineTable({
noteId: v.id('notes'),
userId: v.string(),
task: v.string(),
})
.index('by_noteId', ['noteId'])
.index('by_userId', ['userId']),
});
仪表板
现在我们已经设置好了后端、身份验证以及模式,可以开始获取数据了。登录应用后,用户可以查看他们的仪表盘,其中列出了他们录制的所有语音笔记。
为此,我们首先在凸文件夹中定义一个查询,该查询使用 auth 来接收userId
,验证其有效性,并返回与用户的 匹配的所有注释userId
。
export const getNotes = queryWithUser({
args: {},
handler: async (ctx, args) => {
const userId = ctx.userId;
if (userId === undefined) {
return null;
}
const notes = await ctx.db
.query('notes')
.withIndex('by_userId', (q) => q.eq('userId', userId))
.collect();
const results = Promise.all(
notes.map(async (note) => {
const count = (
await ctx.db
.query('actionItems')
.withIndex('by_noteId', (q) => q.eq('noteId', note._id))
.collect()
).length;
return {
count,
...note,
};
}),
);
return results;
},
});
之后,我们可以getNotes
通过 Convex 提供的函数,使用用户的身份验证令牌调用此查询,以便在仪表板中显示所有用户的笔记。我们使用服务器端渲染来获取服务器上的数据,然后将其传递到<DashboardHomePage />
客户端组件。这也确保了客户端上的数据也保持最新。
import { api } from '@/convex/_generated/api';
import { preloadQuery } from 'convex/nextjs';
import DashboardHomePage from './dashboard';
import { getAuthToken } from '../auth';
const ServerDashboardHomePage = async () => {
const token = await getAuthToken();
const preloadedNotes = await preloadQuery(api.notes.getNotes, {}, { token });
return <DashboardHomePage preloadedNotes={preloadedNotes} />;
};
export default ServerDashboardHomePage;
录制语音笔记
最初,用户的仪表盘上没有任何语音笔记,因此他们可以点击“录制新语音笔记”按钮来录制一条语音笔记。他们将看到以下屏幕,然后开始录制。
这将使用原生浏览器 API 录制一段语音笔记,将文件保存在 Convex 文件存储中,然后通过 Replicate 将其发送到 Whisper 进行转录。我们要做的第一件事是createNote
在 Convex 文件夹中定义一个 Mutation,用于接收这段录音,将一些信息保存在 Convex 数据库中,然后调用 whisper 操作。
export const createNote = mutationWithUser({
args: {
storageId: v.id('_storage'),
},
handler: async (ctx, { storageId }) => {
const userId = ctx.userId;
let fileUrl = (await ctx.storage.getUrl(storageId)) as string;
const noteId = await ctx.db.insert('notes', {
userId,
audioFileId: storageId,
audioFileUrl: fileUrl,
generatingTranscript: true,
generatingTitle: true,
generatingActionItems: true,
});
await ctx.scheduler.runAfter(0, internal.whisper.chat, {
fileUrl,
id: noteId,
});
return noteId;
},
});
耳语操作如下所示。它使用 Replicate 作为耳语的托管服务提供商。
export const chat = internalAction({
args: {
fileUrl: v.string(),
id: v.id('notes'),
},
handler: async (ctx, args) => {
const replicateOutput = (await replicate.run(
'openai/whisper:4d50797290df275329f202e48c76360b3f22b08d28c196cbc54600319435f8d2',
{
input: {
audio: args.fileUrl,
model: 'large-v3',
translate: false,
temperature: 0,
transcription: 'plain text',
suppress_tokens: '-1',
logprob_threshold: -1,
no_speech_threshold: 0.6,
condition_on_previous_text: true,
compression_ratio_threshold: 2.4,
temperature_increment_on_fallback: 0.2,
},
},
)) as whisperOutput;
const transcript = replicateOutput.transcription || 'error';
await ctx.runMutation(internal.whisper.saveTranscript, {
id: args.id,
transcript,
});
},
});
此外,所有这些文件都可以在 Convex 仪表板的“文件”下看到。
生成行动项目
用户完成语音笔记录制并通过 Whisper 转录后,输出将被传入 Together AI。我们同时展示了此加载屏幕。
我们首先定义一个我们希望输出的模式。然后,我们将此模式传入 Together.ai 上托管的 Mixtral 模型,并提示识别语音笔记的摘要、文字记录,并根据文字记录生成操作项。之后,我们将所有这些信息保存到 Convex 数据库中。为此,我们在 convex 文件夹中创建了一个 Convex 操作。
// convex/together.ts
const NoteSchema = z.object({
title: z
.string()
.describe('Short descriptive title of what the voice message is about'),
summary: z
.string()
.describe(
'A short summary in the first person point of view of the person recording the voice message',
)
.max(500),
actionItems: z
.array(z.string())
.describe(
'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',
),
});
export const chat = internalAction({
args: {
id: v.id('notes'),
transcript: v.string(),
},
handler: async (ctx, args) => {
const { transcript } = args;
const extract = await client.chat.completions.create({
messages: [
{
role: 'system',
content:
'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, ...]}',
},
{ role: 'user', content: transcript },
],
model: 'mistralai/Mixtral-8x7B-Instruct-v0.1',
response_model: { schema: NoteSchema, name: 'SummarizeNotes' },
max_tokens: 1000,
temperature: 0.6,
max_retries: 3,
});
const { title, summary, actionItems } = extract;
await ctx.runMutation(internal.together.saveSummary, {
id: args.id,
summary,
actionItems,
title,
});
});
当 Together.ai 回复时,我们会看到最终屏幕,用户可以在左侧的成绩单和摘要之间切换,并在右侧查看和勾选操作项目。
向量搜索
该应用程序的最后一部分是向量搜索。我们使用 Together.ai 嵌入来嵌入文字记录,以便人们能够根据文字记录的语义在仪表板中进行搜索。
我们通过在凸文件夹中创建一个similarNotes
操作来实现这一点,该操作接收用户的搜索查询,为其生成嵌入,并找到最相似的注释显示在页面上。
export const similarNotes = actionWithUser({
args: {
searchQuery: v.string(),
},
handler: async (ctx, args): Promise<SearchResult[]> => {
// 1. Create the embedding
const getEmbedding = await togetherai.embeddings.create({
input: [args.searchQuery.replace('/n', ' ')],
model: 'togethercomputer/m2-bert-80M-32k-retrieval',
});
const embedding = getEmbedding.data[0].embedding;
// 2. Then search for similar notes
const results = await ctx.vectorSearch('notes', 'by_embedding', {
vector: embedding,
limit: 16,
filter: (q) => q.eq('userId', ctx.userId), // Only search my notes.
});
return results.map((r) => ({
id: r._id,
score: r._score,
}));
},
});
结论
就这样,我们构建了一个可用于生产的全栈 AI 应用,它具备身份验证、数据库、存储和 API 功能。欢迎使用notesGPT从你的笔记或GitHub 代码库中生成行动项以供参考。如有任何疑问,请直接给我发私信,我非常乐意为你解答!
文章来源:https://dev.to/nutlope/how-i-built-notesgpt-a-full-stack-ai-voice-note-app-265o