我是如何构建 NotesGPT 的——一款全栈 AI 语音笔记应用

2025-05-27

我是如何构建 NotesGPT 的——一款全栈 AI 语音笔记应用

上周,我发布了notesGPT,这是一款免费开源的语音笔记应用,截至目前,该应用拥有35,000 名访客、7,000 名用户和超过 1,000 个 GitHub 星标。它支持录制语音笔记,使用Whisper进行转录,并通过Together使用 Mixtral提取行动项并将其显示在行动项视图中。它完全开源,配备了身份验证、存储、向量搜索和行动项功能,并且在移动设备上响应迅速,易于使用。

我将向您详细讲解我是如何构建它的。

架构和技术栈

这是架构的简要示意图。我们将更深入地讨论每个部分,并展示代码示例。

架构图

以下是我使用的整体技术栈:

登陆页面

该应用程序的第一部分是您导航到 notesGPT 时看到的登录页面。

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

请查看Convex QuickstartConvex 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']),
});
Enter fullscreen mode Exit fullscreen mode

仪表板

现在我们已经设置好了后端、身份验证以及模式,可以开始获取数据了。登录应用后,用户可以查看他们的仪表盘,其中列出了他们录制的所有语音笔记。

仪表板

为此,我们首先在凸文件夹中定义一个查询,该查询使用 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;
  },
});
Enter fullscreen mode Exit fullscreen mode

之后,我们可以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;
Enter fullscreen mode Exit fullscreen mode

录制语音笔记

最初,用户的仪表盘上没有任何语音笔记,因此他们可以点击“录制新语音笔记”按钮来录制一条语音笔记。他们将看到以下屏幕,然后开始录制。

录制语音笔记页面

这将使用原生浏览器 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;
  },
});
Enter fullscreen mode Exit fullscreen mode

耳语操作如下所示。它使用 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,
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

此外,所有这些文件都可以在 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,
      });
});
Enter fullscreen mode Exit fullscreen mode

当 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,
    }));
  },
});
Enter fullscreen mode Exit fullscreen mode

结论

就这样,我们构建了一个可用于生产的全栈 AI 应用,它具备身份验证、数据库、存储和 API 功能。欢迎使用notesGPT从你的笔记或GitHub 代码库中生成行动项以供参考。如有任何疑问,请直接给我发私信,我非常乐意为你解答!

文章来源:https://dev.to/nutlope/how-i-built-notesgpt-a-full-stack-ai-voice-note-app-265o
PREV
面向学习者的 Github 仓库
NEXT
科技行业的工作文化毒性