├── utils
├── env.ts
├── extractSentenceWithTimestamp.ts
├── getCacheId.ts
├── fp.ts
├── constants
│ └── language.ts
├── extractUrl.ts
├── constants.ts
├── readStream.ts
├── schemas
│ └── video.ts
├── getRedirectUrl.ts
├── fetchWithTimeout.ts
├── getVideoIdFromUrl.ts
├── extractTimestamp.ts
├── formatSummary.ts
└── reduceSubtitleTimestamp.ts
├── public
├── edit.png
├── video.png
├── wechat.jpg
├── BibiGPT.gif
├── favicon.ico
├── og-image.jpg
├── og-image.png
├── tv-logo.png
├── screenshot.png
├── shortcuts.png
├── deploy-ch
│ ├── img_1.png
│ ├── img_2.png
│ ├── img_3.jpg
│ ├── img_4.png
│ ├── img_5.png
│ ├── img_6.png
│ └── img_7.png
├── tv.svg
├── video-off.svg
├── loading.svg
└── vercel.svg
├── .husky
└── pre-commit
├── .prettierignore
├── sentry.properties
├── postcss.config.js
├── styles
└── globals.css
├── lib
├── utils.ts
├── supabase.ts
├── openai
│ ├── trimOpenAiResult.ts
│ ├── checkOpenaiApiKey.ts
│ ├── selectApiKeyAndActivatedLicenseKey.ts
│ ├── getSmallSizeTranscripts.ts
│ ├── fetchOpenAIResult.ts
│ └── prompt.ts
├── types.ts
├── fetchSubtitle.ts
├── upstash.ts
├── youtube
│ ├── fetchYoutubeSubtitleUrls.ts
│ └── fetchYoutubeSubtitle.ts
├── lemon.ts
└── bilibili
│ ├── fetchBilibiliSubtitle.ts
│ └── fetchBilibiliSubtitleUrls.ts
├── .prettierrc
├── .github
├── weekly-digest.yml
├── ISSUE_TEMPLATE
│ ├── enhancement.md
│ ├── document.md
│ ├── error.md
│ └── maintenance.md
├── workflows
│ ├── release-please.yml
│ ├── release-remotion.yml
│ └── release-drafter.yml
└── release-drafter.yml
├── hooks
├── useConfig.ts
├── use-window-size.ts
├── notes
│ ├── flomo.ts
│ └── lark.ts
├── useLocalStorage.ts
├── useSummarize.ts
└── use-toast.ts
├── .releaserc
├── dev.Dockerfile
├── components
├── UsageAction.tsx
├── ui
│ ├── switch-item.tsx
│ ├── label.tsx
│ ├── toaster.tsx
│ ├── slider.tsx
│ ├── tooltip.tsx
│ ├── switch.tsx
│ ├── button.tsx
│ ├── dialog.tsx
│ ├── select.tsx
│ ├── toast.tsx
│ ├── command.tsx
│ └── dropdown-menu.tsx
├── SwitchTimestamp.tsx
├── SaveNoteButton.tsx
├── tailwind-indicator.tsx
├── UsageDescription.tsx
├── SubmitButton.tsx
├── GitHub.tsx
├── SquigglyLines.tsx
├── CommandMenu.tsx
├── TypingSlogan.tsx
├── shared
│ ├── popover.tsx
│ ├── modal.tsx
│ └── leaflet.tsx
├── context
│ └── analytics.tsx
├── SignIn.tsx
├── Sentence.tsx
├── mode-toggle.tsx
├── Footer.tsx
├── ActionsAfterResult.tsx
├── SummaryResult.tsx
├── UserKeyInput.tsx
├── user-dropdown.tsx
├── sign-in-modal.tsx
├── PromptOptions.tsx
├── Header.tsx
└── sidebar.tsx
├── .gitignore
├── tsconfig.json
├── .dockerignore
├── sentry.client.config.js
├── sentry.server.config.js
├── sentry.edge.config.js
├── .example.env
├── docker-compose.yml
├── next.config.js
├── tailwind.config.js
├── pages
├── 404.tsx
├── index.tsx
├── shop.tsx
├── _document.tsx
├── _error.tsx
├── _app.tsx
├── user
│ ├── dashboard.tsx
│ ├── videos.tsx
│ └── integration.tsx
├── api
│ └── sumup.ts
└── [...slug].tsx
├── deploy-ch.md
├── vercel.json
├── Dockerfile
├── package.json
├── README.md
└── middleware.ts
/utils/env.ts:
--------------------------------------------------------------------------------
1 | export const isDev = process.env.NODE_ENV === 'development'
2 |
--------------------------------------------------------------------------------
/public/edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/edit.png
--------------------------------------------------------------------------------
/public/video.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/video.png
--------------------------------------------------------------------------------
/public/wechat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/wechat.jpg
--------------------------------------------------------------------------------
/public/BibiGPT.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/BibiGPT.gif
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/og-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/og-image.jpg
--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/og-image.png
--------------------------------------------------------------------------------
/public/tv-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/tv-logo.png
--------------------------------------------------------------------------------
/public/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/screenshot.png
--------------------------------------------------------------------------------
/public/shortcuts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/shortcuts.png
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/public/deploy-ch/img_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/deploy-ch/img_1.png
--------------------------------------------------------------------------------
/public/deploy-ch/img_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/deploy-ch/img_2.png
--------------------------------------------------------------------------------
/public/deploy-ch/img_3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/deploy-ch/img_3.jpg
--------------------------------------------------------------------------------
/public/deploy-ch/img_4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/deploy-ch/img_4.png
--------------------------------------------------------------------------------
/public/deploy-ch/img_5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/deploy-ch/img_5.png
--------------------------------------------------------------------------------
/public/deploy-ch/img_6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/deploy-ch/img_6.png
--------------------------------------------------------------------------------
/public/deploy-ch/img_7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JimmyLv/BibiGPT-v1/HEAD/public/deploy-ch/img_7.png
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | build
3 | coverage
4 | .next
5 |
6 | # Ignore all HTML files:
7 | *.html
8 |
--------------------------------------------------------------------------------
/sentry.properties:
--------------------------------------------------------------------------------
1 | defaults.url=https://sentry.io/
2 | defaults.org=mofasi
3 | defaults.project=javascript-nextjs
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | ::selection {
6 | background: #fc8bab;
7 | }
8 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/utils/extractSentenceWithTimestamp.ts:
--------------------------------------------------------------------------------
1 | export function extractSentenceWithTimestamp(sentence: string) {
2 | return sentence
3 | .replace('0:', '0.0') // 修复第0秒
4 | .match(/^\s*(\d+[\.:]?\d+?)([::秒 ].*)/)
5 | }
6 |
--------------------------------------------------------------------------------
/lib/supabase.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@supabase/supabase-js'
2 |
3 | const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
4 |
5 | export default supabase
6 |
--------------------------------------------------------------------------------
/lib/openai/trimOpenAiResult.ts:
--------------------------------------------------------------------------------
1 | export function trimOpenAiResult(result: any) {
2 | const answer = result.choices[0].message?.content || ''
3 | if (answer.startsWith('\n\n')) {
4 | return answer.substring(2)
5 | }
6 | return answer
7 | }
8 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "printWidth": 120,
6 | "overrides": [
7 | {
8 | "files": ".prettierrc",
9 | "options": { "parser": "json" }
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/.github/weekly-digest.yml:
--------------------------------------------------------------------------------
1 | # Configuration for weekly-digest - https://github.com/apps/weekly-digest
2 | publishDay: sun
3 | canPublishIssues: true
4 | canPublishPullRequests: true
5 | canPublishContributors: true
6 | canPublishStargazers: true
7 | canPublishCommits: true
8 |
--------------------------------------------------------------------------------
/utils/getCacheId.ts:
--------------------------------------------------------------------------------
1 | import { VideoConfig } from '~/lib/types'
2 |
3 | export function getCacheId({ showTimestamp, videoId, outputLanguage, detailLevel }: VideoConfig) {
4 | const prefix = showTimestamp ? 'timestamp-' : ''
5 | return `${prefix}${videoId}-${outputLanguage}-${detailLevel}`
6 | }
7 |
--------------------------------------------------------------------------------
/hooks/useConfig.ts:
--------------------------------------------------------------------------------
1 | import { useLocalStorage } from './useLocalStorage'
2 |
3 | export const useWriteKey = () =>
4 | useLocalStorage('segment_playground_write_key', process.env.NEXT_PUBLIC_SEGMENT_WRITEKEY)
5 |
6 | export const useCDNUrl = () => useLocalStorage('segment_playground_cdn_url', 'https://cdn.segment.com')
7 |
--------------------------------------------------------------------------------
/public/tv.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | {
2 | "branches": ["main", "next"],
3 | "plugins": [
4 | "@semantic-release/commit-analyzer",
5 | "@semantic-release/release-notes-generator",
6 | "@semantic-release/git",
7 | "@semantic-release/github",
8 | ["@semantic-release/npm", {
9 | "npmPublish": false
10 | }]
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/enhancement.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 功能改善
3 | about: 欢迎分享您的改善建议!
4 | title: "🚀 功能改善 "
5 | labels: ["enhancement"]
6 | ---
7 |
8 | # 功能改善建议 🚀
9 |
10 | 欢迎在此分享您对功能的改善建议,我们期待听到您的想法和建议。
11 |
12 | ## 您的建议是什么? 🤔
13 |
14 | 请简要描述您的功能改善建议,包括您的目标和想法。
15 |
16 | 如果您的建议是解决某个特定问题的,请尽可能提供更多的上下文和细节。
17 |
18 |
19 | 感谢您的分享和支持!🙏
20 |
--------------------------------------------------------------------------------
/dev.Dockerfile:
--------------------------------------------------------------------------------
1 | # This is a dockerfile for a development environment,
2 | # so you don't have to worry about missing environment variables during the build.
3 |
4 | FROM node:alpine
5 |
6 | WORKDIR /app
7 | COPY . .
8 |
9 | ENV NODE_ENV development
10 | ENV PORT 3000
11 |
12 | RUN rm -rf .env && npm ci
13 |
14 | EXPOSE 3000
15 | CMD ["node_modules/.bin/next", "dev"]
--------------------------------------------------------------------------------
/utils/fp.ts:
--------------------------------------------------------------------------------
1 | export const sample = (arr: any[] = []) => {
2 | const len = arr === null ? 0 : arr.length
3 | return len ? arr[Math.floor(Math.random() * len)] : undefined
4 | }
5 |
6 | export function find(subtitleList: any[] = [], args: { [key: string]: any }) {
7 | const key = Object.keys(args)[0]
8 | return subtitleList.find((item) => item[key] === args[key])
9 | }
10 |
--------------------------------------------------------------------------------
/public/video-off.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/lib/openai/checkOpenaiApiKey.ts:
--------------------------------------------------------------------------------
1 | export function checkOpenaiApiKey(str: string) {
2 | var pattern = /^sk-[A-Za-z0-9]{48}$/
3 | return pattern.test(str)
4 | }
5 |
6 | export function checkOpenaiApiKeys(str: string) {
7 | if (str.includes(',')) {
8 | const userApiKeys = str.split(',')
9 | return userApiKeys.every(checkOpenaiApiKey)
10 | }
11 |
12 | return checkOpenaiApiKey(str)
13 | }
14 |
--------------------------------------------------------------------------------
/utils/constants/language.ts:
--------------------------------------------------------------------------------
1 | export const PROMPT_LANGUAGE_MAP: { [key: string]: string } = {
2 | English: 'en-US',
3 | 中文: 'zh-CN',
4 | 繁體中文: 'zh-TW',
5 | 日本語: 'ja-JP',
6 | Italiano: 'it-IT',
7 | Deutsch: 'de-DE',
8 | Español: 'es-ES',
9 | Français: 'fr-FR',
10 | Nederlands: 'nl-NL',
11 | 한국어: 'ko-KR',
12 | ភាសាខ្មែរ: 'km-KH',
13 | हिंदी: 'hi-IN',
14 | }
15 |
16 | export const DEFAULT_LANGUAGE = 'zh-CN'
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/document.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 文档改善
3 | about: 欢迎分享您的文档改善建议!
4 | title: "📝 文档改善 "
5 | labels: ["documentation"]
6 | ---
7 |
8 | # 文档改善建议 📝
9 |
10 | 欢迎在此分享您对文档的改善建议,我们期待听到您的想法和建议。
11 |
12 | ## 您的建议是什么? 🤔
13 |
14 | 请简要描述您的文档改善建议,包括您的目标和想法。
15 |
16 | 如果您的建议是解决某个特定问题的,请尽可能提供更多的上下文和细节。
17 |
18 | ## 您的建议有哪些优势? 🌟
19 |
20 | 请简要描述您的建议的优势和特点,比如:
21 |
22 | - 是否可以提高文档的可读性和易用性?
23 | - 是否可以使文档更加详细和准确?
24 | - 是否可以让文档更好地反映项目的实际情况?
25 |
26 | 感谢您的分享和支持!🙏
27 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/error.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 错误报告
3 | about: 提出关于此项目的错误报告
4 | title: "🐞 错误报告 "
5 | labels: ["bug"]
6 | ---
7 |
8 | # 错误报告 🐞
9 |
10 | 如果您在使用此项目时遇到了错误,请在此报告,我们会尽快解决此问题。
11 |
12 | ## 错误描述 🤔
13 |
14 | 请详细地描述您遇到的问题,包括出现问题的环境和步骤,以及您已经尝试过的解决方法。
15 |
16 | 另外,如果您在解决问题时已经查看过其他 GitHub Issue,请务必在文本中说明并引用相关信息。
17 |
18 | ## 附加信息 📝
19 |
20 | 请提供以下信息以帮助我们更快地解决问题:
21 |
22 | - 输出日志,包括错误信息和堆栈跟踪
23 | - 相关的代码片段或文件
24 | - 您的操作系统、软件版本等环境信息
25 |
26 | 感谢您的反馈!🙏
27 |
--------------------------------------------------------------------------------
/utils/extractUrl.ts:
--------------------------------------------------------------------------------
1 | export function extractUrl(videoUrl: string) {
2 | const matchResult = videoUrl.match(/\/video\/([^\/\?]+)/)
3 | if (!matchResult) {
4 | return
5 | }
6 | return matchResult[1]
7 | }
8 |
9 | export function extractPage(currentVideoUrl: string, searchParams: URLSearchParams) {
10 | const queryString = currentVideoUrl.split('?')[1]
11 | const urlParams = new URLSearchParams(queryString)
12 | return searchParams.get('p') || urlParams.get('p')
13 | }
14 |
--------------------------------------------------------------------------------
/utils/constants.ts:
--------------------------------------------------------------------------------
1 | export const BASE_DOMAIN = 'https://b.jimmylv.cn'
2 | export const CHECKOUT_URL = 'https://jimmylv.lemonsqueezy.com/checkout/buy/e0c93804-abcc-47f7-848c-8756bec0e2fb'
3 | export const LOGIN_LIMIT_COUNT = 5
4 | export const FREE_LIMIT_COUNT = 5
5 | export const RATE_LIMIT_COUNT = LOGIN_LIMIT_COUNT + FREE_LIMIT_COUNT
6 |
7 | export const FADE_IN_ANIMATION_SETTINGS = {
8 | initial: { opacity: 0 },
9 | animate: { opacity: 1 },
10 | transition: { duration: 0.2 },
11 | }
12 |
--------------------------------------------------------------------------------
/public/loading.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/components/UsageAction.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export function UsageAction() {
4 | return (
5 |
6 | 在下面的输入框,直接复制粘贴
7 |
13 | {' bilibili.com/youtube.com '}
14 |
15 | 视频链接 👇
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/utils/readStream.ts:
--------------------------------------------------------------------------------
1 | export default async function readStream(response: Response, callback: any) {
2 | const data = response.body
3 | if (!data) {
4 | return
5 | }
6 |
7 | const reader = data.getReader()
8 | const decoder = new TextDecoder()
9 | let done = false
10 |
11 | while (!done) {
12 | const { value, done: doneReading } = await reader.read()
13 | done = doneReading
14 | const chunkValue = decoder.decode(value)
15 | callback((prev: string) => prev + chunkValue)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/utils/schemas/video.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const videoConfigSchema = z.object({
4 | // videoId: z.string(),
5 | enableStream: z.boolean().optional(),
6 | showTimestamp: z.boolean().optional(),
7 | showEmoji: z.boolean().optional(),
8 | outputLanguage: z.string().optional(),
9 | detailLevel: z.number().optional(),
10 | sentenceNumber: z.number().optional(),
11 | outlineLevel: z.number().optional(),
12 | })
13 |
14 | export type VideoConfigSchema = z.infer
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/maintenance.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 项目维护
3 | about: 欢迎提交您的项目维护问题和建议!
4 | title: "🔧 项目维护 "
5 | labels: ["maintenance"]
6 | ---
7 |
8 | # 项目维护问题和建议 🔧
9 |
10 | 欢迎在此分享您对项目维护的问题和建议,我们期待听到您的想法和建议。
11 |
12 | ## 您的问题或建议是什么? 🤔
13 |
14 | 请简要描述您遇到的项目维护问题或者您的项目维护建议,包括您的目标和想法。
15 |
16 | 注意:如果您的建议涉及到以下方面,请在描述中加以说明,以帮助我们更好地理解您的意见。
17 |
18 | - 代码重构
19 | - 设计模式加强
20 | - 优化算法
21 | - 依赖升级
22 |
23 | ## 您期望的解决方案是什么? 💡
24 |
25 | 请简要描述您期望的解决方案,包括您的期望和想法。
26 |
27 | 如果您期望的解决方案是解决某个特定问题的,请尽可能提供更多的上下文和细节。
28 |
29 | 感谢您的分享和支持!🙏
30 |
--------------------------------------------------------------------------------
/utils/getRedirectUrl.ts:
--------------------------------------------------------------------------------
1 | export const getRedirectURL = () => {
2 | let url =
3 | process?.env?.NEXT_PUBLIC_SITE_URL ?? // Set this to your site URL in production env.
4 | process?.env?.NEXT_PUBLIC_VERCEL_URL ?? // Automatically set by Vercel.
5 | 'http://localhost:3000/'
6 | // Make sure to include `https://` when not localhost.
7 | url = url.includes('http') ? url : `https://${url}`
8 | // Make sure to including trailing `/`.
9 | url = url.charAt(url.length - 1) === '/' ? url : `${url}/`
10 | return url
11 | }
12 |
--------------------------------------------------------------------------------
/.github/workflows/release-please.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - main
5 |
6 | permissions:
7 | contents: write
8 | pull-requests: write
9 |
10 | name: release-please
11 |
12 | jobs:
13 | release-please:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: google-github-actions/release-please-action@v3
17 | id: release
18 | with:
19 | release-type: node
20 | token: ${{secrets.PAT_TOKEN}}
21 | default-branch: main
22 | package-name: bibi-gpt
23 |
--------------------------------------------------------------------------------
/utils/fetchWithTimeout.ts:
--------------------------------------------------------------------------------
1 | interface Options extends RequestInit {
2 | /** timeout, default: 8000ms */
3 | timeout?: number
4 | }
5 |
6 | export async function fetchWithTimeout(resource: RequestInfo | URL, options: Options = {}) {
7 | const { timeout } = options
8 |
9 | const controller = new AbortController()
10 | const id = setTimeout(() => controller.abort(), timeout)
11 | const response = await fetch(resource, {
12 | ...options,
13 | signal: controller.signal,
14 | })
15 | clearTimeout(id)
16 | return response
17 | }
18 |
--------------------------------------------------------------------------------
/components/ui/switch-item.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Label } from '~/components/ui/label'
3 | import { Switch } from '~/components/ui/switch'
4 |
5 | export function SwitchItem(props: {
6 | checked: undefined | boolean
7 | onCheckedChange: (checked: boolean) => void
8 | title: string
9 | id: string
10 | }) {
11 | return (
12 |
13 |
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/components/SwitchTimestamp.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Label } from '~/components/ui/label'
3 | import { Switch } from '~/components/ui/switch'
4 |
5 | export function SwitchTimestamp(props: { checked: undefined | boolean; onCheckedChange: (checked: boolean) => void }) {
6 | return (
7 |
8 |
9 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/components/SaveNoteButton.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import React from 'react'
3 |
4 | export function SaveNoteButton({
5 | text,
6 | loading,
7 | onSave,
8 | }: {
9 | text: string
10 | onSave: () => Promise
11 | loading: boolean
12 | }) {
13 | return (
14 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 | .idea/
32 |
33 | # vercel
34 | .vercel
35 |
36 | # typescript
37 | *.tsbuildinfo
38 | next-env.d.ts
39 |
40 | # Sentry
41 | .sentryclirc
42 |
43 | # Sentry
44 | next.config.original.js
45 |
46 | # lockfile
47 | pnpm-lock.yaml
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as LabelPrimitive from '@radix-ui/react-label'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Label = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Label.displayName = LabelPrimitive.Root.displayName
22 |
23 | export { Label }
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "~/*": ["./*"],
20 | "@/*": ["./*"]
21 | }
22 | },
23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
24 | "exclude": ["node_modules"]
25 | }
26 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 | # .env
31 | .idea/
32 |
33 | # vercel
34 | .vercel
35 |
36 | # typescript
37 | *.tsbuildinfo
38 | next-env.d.ts
39 |
40 | # Sentry
41 | .sentryclirc
42 |
43 | # Sentry
44 | next.config.original.js
45 |
46 | # lockfile
47 | pnpm-lock.yaml
48 | yarn.lock
--------------------------------------------------------------------------------
/components/tailwind-indicator.tsx:
--------------------------------------------------------------------------------
1 | export function TailwindIndicator() {
2 | if (process.env.NODE_ENV === 'production') return null
3 |
4 | return (
5 |
6 |
xs
7 |
sm
8 |
md
9 |
lg
10 |
xl
11 |
2xl
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/utils/getVideoIdFromUrl.ts:
--------------------------------------------------------------------------------
1 | export function getVideoIdFromUrl(
2 | isReady: boolean,
3 | currentVideoUrl: string,
4 | urlState?: string | string[],
5 | searchParams?: URLSearchParams,
6 | ): string | undefined {
7 | // todo: replace urlState to usePathname() https://beta.nextjs.org/docs/api-reference/use-pathname
8 | const isValidatedUrl =
9 | isReady &&
10 | !currentVideoUrl &&
11 | urlState &&
12 | typeof urlState !== 'string' &&
13 | urlState.every((subslug: string) => typeof subslug === 'string')
14 |
15 | if (isValidatedUrl) {
16 | if (urlState[0] === 'watch') {
17 | return 'https://youtube.com/watch?v=' + searchParams?.get('v')
18 | }
19 | return `https://bilibili.com/${(urlState as string[]).join('/')}`
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name-template: 'v$RESOLVED_VERSION 🌈'
2 | tag-template: 'v$RESOLVED_VERSION'
3 | categories:
4 | - title: '🚀 Features'
5 | labels:
6 | - 'feature'
7 | - 'enhancement'
8 | - title: '🐛 Bug Fixes'
9 | labels:
10 | - 'fix'
11 | - 'bugfix'
12 | - 'bug'
13 | - title: '🧰 Maintenance'
14 | label: 'chore'
15 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
16 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
17 | version-resolver:
18 | major:
19 | labels:
20 | - 'major'
21 | minor:
22 | labels:
23 | - 'minor'
24 | patch:
25 | labels:
26 | - 'patch'
27 | default: patch
28 | template: |
29 | ## Changes
30 |
31 | $CHANGES
32 |
--------------------------------------------------------------------------------
/lib/types.ts:
--------------------------------------------------------------------------------
1 | import { VideoConfigSchema } from '~/utils/schemas/video'
2 |
3 | export type SummarizeParams = {
4 | videoConfig: VideoConfig
5 | userConfig: UserConfig
6 | }
7 | export type UserConfig = {
8 | userKey?: string
9 | shouldShowTimestamp?: boolean
10 | }
11 | export type VideoConfig = {
12 | videoId: string
13 | service?: VideoService
14 | pageNumber?: null | string
15 | } & VideoConfigSchema
16 |
17 | export enum VideoService {
18 | Bilibili = 'bilibili',
19 | Youtube = 'youtube',
20 | // todo: integrate with whisper API
21 | Podcast = 'podcast',
22 | Meeting = 'meeting',
23 | LocalVideo = 'local-video',
24 | LocalAudio = 'local-audio',
25 | }
26 |
27 | export type CommonSubtitleItem = {
28 | text: string
29 | index: number
30 | s?: number | string
31 | }
32 |
--------------------------------------------------------------------------------
/lib/fetchSubtitle.ts:
--------------------------------------------------------------------------------
1 | import { fetchBilibiliSubtitle } from './bilibili/fetchBilibiliSubtitle'
2 | import { CommonSubtitleItem, VideoConfig, VideoService } from './types'
3 | import { fetchYoutubeSubtitle } from './youtube/fetchYoutubeSubtitle'
4 |
5 | export async function fetchSubtitle(
6 | videoConfig: VideoConfig,
7 | shouldShowTimestamp?: boolean,
8 | ): Promise<{
9 | title: string
10 | subtitlesArray?: null | Array
11 | descriptionText?: string
12 | }> {
13 | const { service, videoId, pageNumber } = videoConfig
14 | console.log('video: ', videoConfig)
15 | if (service === VideoService.Youtube) {
16 | return await fetchYoutubeSubtitle(videoId, shouldShowTimestamp)
17 | }
18 | return await fetchBilibiliSubtitle(videoId, pageNumber, shouldShowTimestamp)
19 | }
20 |
--------------------------------------------------------------------------------
/sentry.client.config.js:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the browser.
2 | // The config you add here will be used whenever a page is visited.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs'
6 |
7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN
8 |
9 | Sentry.init({
10 | dsn: SENTRY_DSN || 'https://787cffbb40014f4d92dd9159ddf7a562@o4504790569254912.ingest.sentry.io/4504790583214080',
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1.0,
13 | // ...
14 | // Note: if you want to override the automatic release value, do not set a
15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so
16 | // that it will also get attached to your source maps
17 | })
18 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useToast } from '@/hooks/use-toast'
4 |
5 | import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast'
6 |
7 | export function Toaster() {
8 | const { toasts } = useToast()
9 |
10 | return (
11 |
12 | {toasts.map(function ({ id, title, description, action, ...props }) {
13 | return (
14 |
15 |
16 | {title && {title}}
17 | {description && {description}}
18 |
19 | {action}
20 |
21 |
22 | )
23 | })}
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/sentry.server.config.js:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the server.
2 | // The config you add here will be used whenever the server handles a request.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs'
6 |
7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN
8 |
9 | Sentry.init({
10 | dsn: SENTRY_DSN || 'https://787cffbb40014f4d92dd9159ddf7a562@o4504790569254912.ingest.sentry.io/4504790583214080',
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1.0,
13 | // ...
14 | // Note: if you want to override the automatic release value, do not set a
15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so
16 | // that it will also get attached to your source maps
17 | })
18 |
--------------------------------------------------------------------------------
/sentry.edge.config.js:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the server.
2 | // The config you add here will be used whenever middleware or an Edge route handles a request.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from '@sentry/nextjs'
6 |
7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN
8 |
9 | Sentry.init({
10 | dsn: SENTRY_DSN || 'https://787cffbb40014f4d92dd9159ddf7a562@o4504790569254912.ingest.sentry.io/4504790583214080',
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1.0,
13 | // ...
14 | // Note: if you want to override the automatic release value, do not set a
15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so
16 | // that it will also get attached to your source maps
17 | })
18 |
--------------------------------------------------------------------------------
/components/UsageDescription.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export function UsageDescription() {
4 | return (
5 |
11 | 你只需要把任意 Bilibili 视频 URL 中的后缀 ".com" 改成我的域名 "
12 | jimmylv.cn" 就行啦!😉
13 |
14 | 比如 www.bilibili.
15 | com
16 | /video/BV1k84y1e7fW 👉 www.bilibili.
17 | jimmylv.cn
18 | /video/BV1k84y1e7fW
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/lib/openai/selectApiKeyAndActivatedLicenseKey.ts:
--------------------------------------------------------------------------------
1 | import { activateLicenseKey } from '~/lib/lemon'
2 | import { checkOpenaiApiKeys } from '~/lib/openai/checkOpenaiApiKey'
3 | import { sample } from '~/utils/fp'
4 |
5 | export async function selectApiKeyAndActivatedLicenseKey(apiKey?: string, videoId?: string) {
6 | if (apiKey) {
7 | if (checkOpenaiApiKeys(apiKey)) {
8 | const userApiKeys = apiKey.split(',')
9 | return sample(userApiKeys)
10 | }
11 |
12 | // user is using validated licenseKey
13 | const activated = await activateLicenseKey(apiKey, videoId)
14 | if (!activated) {
15 | throw new Error('licenseKey is not validated!')
16 | }
17 | }
18 |
19 | // don't need to validate anymore, already verified in middleware?
20 | const myApiKeyList = process.env.OPENAI_API_KEY
21 | const luckyApiKey = sample(myApiKeyList?.split(','))
22 | return luckyApiKey || ''
23 | }
24 |
--------------------------------------------------------------------------------
/components/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image'
2 | import React from 'react'
3 |
4 | export function SubmitButton({ loading }: { loading: boolean }) {
5 | if (!loading) {
6 | return (
7 |
13 | )
14 | }
15 |
16 | return (
17 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/lib/upstash.ts:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from '@upstash/ratelimit'
2 | import { Redis } from '@upstash/redis'
3 | import { FREE_LIMIT_COUNT, LOGIN_LIMIT_COUNT } from '~/utils/constants'
4 |
5 | const redis = new Redis({
6 | url: process.env.UPSTASH_RATE_REDIS_REST_URL,
7 | token: process.env.UPSTASH_RATE_REDIS_REST_TOKEN,
8 | })
9 |
10 | export const ratelimitForIps = new Ratelimit({
11 | redis,
12 | // 速率限制算法 https://github.com/upstash/ratelimit#ratelimiting-algorithms
13 | limiter: Ratelimit.fixedWindow(FREE_LIMIT_COUNT, '1 d'),
14 | analytics: true, // <- Enable analytics
15 | })
16 | export const ratelimitForApiKeyIps = new Ratelimit({
17 | redis,
18 | limiter: Ratelimit.fixedWindow(FREE_LIMIT_COUNT * 2, '1 d'),
19 | analytics: true,
20 | })
21 | export const ratelimitForFreeAccounts = new Ratelimit({
22 | redis,
23 | limiter: Ratelimit.fixedWindow(LOGIN_LIMIT_COUNT, '1 d'),
24 | analytics: true,
25 | })
26 |
--------------------------------------------------------------------------------
/.example.env:
--------------------------------------------------------------------------------
1 | # https://platform.openai.com/account/api-keys
2 | OPENAI_API_KEY=sk-xxx
3 |
4 | # https://www.bilibili.com
5 | BILIBILI_SESSION_TOKEN=
6 |
7 | # https://savesubs.com
8 | SAVESUBS_X_AUTH_TOKEN=
9 |
10 | # https://upstash.com
11 | UPSTASH_REDIS_REST_URL=
12 | UPSTASH_REDIS_REST_TOKEN=
13 | UPSTASH_RATE_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL}
14 | UPSTASH_RATE_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN}
15 |
16 | # https://supabase.com/docs/reference/javascript/initializing
17 | SUPABASE_HOSTNAME=xxxx.supabase.co
18 | NEXT_PUBLIC_SUPABASE_ANON_KEY=
19 | NEXT_PUBLIC_SUPABASE_URL=https://${SUPABASE_HOSTNAME}
20 |
21 |
22 | ############ Optional #################
23 |
24 | # https://docs.sentry.io/product/cli/configuration
25 | SENTRY_AUTH_TOKEN=
26 |
27 | # https://www.lemonsqueezy.com
28 | LEMON_API_KEY=
29 |
30 | # https://segment.com
31 | NEXT_PUBLIC_SEGMENT_WRITEKEY=
32 |
33 | NEXT_PUBLIC_SITE_URL=
34 | INTERNAL_API_HOSTNAME=api.example.com
35 |
--------------------------------------------------------------------------------
/components/GitHub.tsx:
--------------------------------------------------------------------------------
1 | export default function Github({ width = '36', height = '36' }: { width: string; height: string }) {
2 | return (
3 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # Hi, this is a Docker service startup configuration.
2 | # Please make sure to configure the .env file in this project's root directory before proceeding.
3 | # you can directly execute the following command to start the service.
4 | # $ docker compose up -d
5 |
6 | # Choose one of the following two services below.
7 | # If you do not need a particular service, please try to comment it out.
8 |
9 | version: '3.9'
10 | services:
11 | # Service 1> Production environment image
12 | bibigpt:
13 | build:
14 | context: ./
15 | dockerfile: Dockerfile
16 | args:
17 | # If you want to use sentry, please set IS_USED_SENTRY=1
18 | IS_USED_SENTRY: 0
19 | container_name: bibigpt
20 | ports:
21 | - 3000:3000
22 |
23 | # Service 2> Development environment image
24 | bibigpt-dev:
25 | build:
26 | context: ./
27 | dockerfile: dev.Dockerfile
28 | container_name: bibigpt-dev
29 | ports:
30 | - 3002:3000
31 | env_file: .env
32 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/release-remotion.yml:
--------------------------------------------------------------------------------
1 | name: 'Relano - Automated Whats new videos'
2 | on:
3 | release:
4 | types: [published, edited]
5 |
6 | jobs:
7 | run-whats-new:
8 | name: "What's new"
9 | runs-on: ubuntu-latest
10 | environment: Production
11 | steps:
12 | - name: Checkout repository
13 | uses: actions/checkout@v2
14 | - name: Set up Node.js
15 | uses: actions/setup-node@v1
16 | with:
17 | node-version: 16
18 | - name: Run Relano
19 | uses: "Just-Moh-it/relano@v1"
20 | with:
21 | openai-api-key: ${{ secrets.OPENAI_API_KEY }}
22 | releaseNotes: ${{ github.event.release.body }}
23 | repositorySlug: ${{ github.repository }}
24 | releaseTag: ${{ github.event.release.tag_name }}
25 | langCode: 'zh'
26 | - name: Upload artifact
27 | uses: actions/upload-artifact@v2
28 | with:
29 | name: ${{ github.repository }}-${{ github.event.release.tag_name }}.mp4
30 | path: ${{ github.repository }}-${{ github.event.release.tag_name }}.mp4
31 |
--------------------------------------------------------------------------------
/components/SquigglyLines.tsx:
--------------------------------------------------------------------------------
1 | export default function SquigglyLines() {
2 | return (
3 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | // This file sets a custom webpack configuration to use your Next.js app
2 | // with Sentry.
3 | // https://nextjs.org/docs/api-reference/next.config.js/introduction
4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
5 | const { withSentryConfig } = require('@sentry/nextjs')
6 |
7 | /** @type {import('next').NextConfig} */
8 | module.exports = {
9 | reactStrictMode: true,
10 | images: {
11 | domains: [
12 | process.env.SUPABASE_HOSTNAME || 'xxxx.supabase.co', // to prevent vercel failed
13 | 'b.jimmylv.cn',
14 | 'avatars.dicebear.com',
15 | // "i2.hdslb.com",
16 | // "avatars.githubusercontent.com",
17 | // "s3-us-west-2.amazonaws.com",
18 | ],
19 | },
20 | async rewrites() {
21 | return [
22 | {
23 | source: '/api/:path*',
24 | destination: `${process.env.INTERNAL_API_HOSTNAME || ''}/api/:path*`,
25 | },
26 | {
27 | source: '/blocked',
28 | destination: '/shop',
29 | },
30 | ]
31 | },
32 | }
33 |
34 | module.exports = withSentryConfig(module.exports, { silent: true }, { hideSourcemaps: true })
35 |
--------------------------------------------------------------------------------
/components/CommandMenu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CommandDialog,
3 | CommandEmpty,
4 | CommandGroup,
5 | CommandInput,
6 | CommandItem,
7 | CommandList,
8 | CommandSeparator,
9 | CommandShortcut,
10 | } from '@/components/ui/command'
11 | import React from 'react'
12 |
13 | export default function CommandMenu() {
14 | const [open, setOpen] = React.useState(false)
15 |
16 | React.useEffect(() => {
17 | const down = (e: KeyboardEvent) => {
18 | if (e.key === 'k' && e.metaKey) {
19 | setOpen((open) => !open)
20 | }
21 | }
22 | document.addEventListener('keydown', down)
23 | return () => document.removeEventListener('keydown', down)
24 | }, [])
25 |
26 | return (
27 |
28 |
29 |
30 | No results found.
31 |
32 | Hello World!
33 | Coming Soon...
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { fontFamily } = require('tailwindcss/defaultTheme')
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | darkMode: ['class', '[data-theme="dark"]'],
6 | content: [
7 | './pages/**/*.{js,ts,jsx,tsx}',
8 | './components/**/*.{js,ts,jsx,tsx}',
9 | './app/**/*.{js,ts,jsx,tsx}',
10 | './node_modules/flowbite/**/*.js',
11 | './node_modules/flowbite-react/**/*.js',
12 | ],
13 | theme: {
14 | extend: {
15 | fontFamily: {
16 | sans: ['var(--font-sans)', ...fontFamily.sans],
17 | },
18 | keyframes: {
19 | 'accordion-down': {
20 | from: { height: 0 },
21 | to: { height: 'var(--radix-accordion-content-height)' },
22 | },
23 | 'accordion-up': {
24 | from: { height: 'var(--radix-accordion-content-height)' },
25 | to: { height: 0 },
26 | },
27 | },
28 | animation: {
29 | 'accordion-down': 'accordion-down 0.2s ease-out',
30 | 'accordion-up': 'accordion-up 0.2s ease-out',
31 | },
32 | },
33 | },
34 | plugins: [require('flowbite/plugin'), require('tailwindcss-animate')],
35 | }
36 |
--------------------------------------------------------------------------------
/pages/404.tsx:
--------------------------------------------------------------------------------
1 | export default () => {
2 | return (
3 |
4 |
5 |
6 |
7 | 404
8 |
9 |
10 | Something's missing.
11 |
12 |
13 | Sorry, we can't find that page. You'll find lots to explore on the home page.{' '}
14 |
15 |
19 | Back to Homepage
20 |
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/hooks/use-window-size.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export default function useWindowSize() {
4 | const [windowSize, setWindowSize] = useState<{
5 | width: number | undefined
6 | height: number | undefined
7 | }>({
8 | width: undefined,
9 | height: undefined,
10 | })
11 |
12 | useEffect(() => {
13 | // Handler to call on window resize
14 | function handleResize() {
15 | // Set window width/height to state
16 | setWindowSize({
17 | width: window.innerWidth,
18 | height: window.innerHeight,
19 | })
20 | }
21 |
22 | // Add event listener
23 | window.addEventListener('resize', handleResize)
24 |
25 | // Call handler right away so state gets updated with initial window size
26 | handleResize()
27 |
28 | // Remove event listener on cleanup
29 | return () => window.removeEventListener('resize', handleResize)
30 | }, []) // Empty array ensures that effect is only run on mount
31 |
32 | return {
33 | windowSize,
34 | isMobile: typeof windowSize?.width === 'number' && windowSize?.width < 768,
35 | isDesktop: typeof windowSize?.width === 'number' && windowSize?.width >= 768,
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { AnalyticsBrowser } from '@segment/analytics-next'
2 | import { Analytics as AnalyticsType } from '@segment/analytics-next/dist/types/core/analytics'
3 | import { NextPage } from 'next'
4 | import React, { useEffect, useState } from 'react'
5 | import { useAnalytics } from '~/components/context/analytics'
6 | import SlugPage from './[...slug]'
7 |
8 | const Home: NextPage<{
9 | showSingIn: (show: boolean) => void
10 | }> = ({ showSingIn }) => {
11 | const [analytics, setAnalytics] = useState(undefined)
12 |
13 | const { analytics: analyticsBrowser } = useAnalytics()
14 | useEffect(() => {
15 | async function handleAnalyticsLoading(browser: AnalyticsBrowser) {
16 | try {
17 | const [response, ctx] = await browser
18 | setAnalytics(response)
19 | // @ts-ignore
20 | window.analytics = response
21 | window.analytics?.page()
22 | } catch (err) {
23 | console.error(err)
24 | setAnalytics(undefined)
25 | }
26 | }
27 | handleAnalyticsLoading(analyticsBrowser).catch(console.error)
28 | }, [analyticsBrowser])
29 | return
30 | }
31 | export default Home
32 |
--------------------------------------------------------------------------------
/lib/youtube/fetchYoutubeSubtitleUrls.ts:
--------------------------------------------------------------------------------
1 | export const SUBTITLE_DOWNLOADER_URL = 'https://savesubs.com'
2 | export async function fetchYoutubeSubtitleUrls(videoId: string) {
3 | const response = await fetch(SUBTITLE_DOWNLOADER_URL + '/action/extract', {
4 | method: 'POST',
5 | body: JSON.stringify({
6 | data: { url: `https://www.youtube.com/watch?v=${videoId}` },
7 | }),
8 | headers: {
9 | 'Content-Type': 'text/plain',
10 | 'User-Agent':
11 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
12 | 'X-Auth-Token': `${process.env.SAVESUBS_X_AUTH_TOKEN}` || '',
13 | 'X-Requested-Domain': 'savesubs.com',
14 | 'X-Requested-With': 'xmlhttprequest',
15 | },
16 | })
17 | const { response: json = {} } = await response.json()
18 | // console.log("========json========", json);
19 | /*
20 | * "title": "Microsoft vs Google: AI War Explained | tech",
21 | "duration": "13 minutes and 15 seconds",
22 | "duration_raw": "795",
23 | "uploader": "Joma Tech / 2023-02-20",
24 | "thumbnail": "//i.ytimg.com/vi/BdHaeczStRA/mqdefault.jpg",
25 | * */
26 | return { title: json.title, subtitleList: json.formats }
27 | }
28 |
--------------------------------------------------------------------------------
/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SliderPrimitive from '@radix-ui/react-slider'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Slider = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
17 |
18 |
19 |
20 |
21 |
22 | ))
23 | Slider.displayName = SliderPrimitive.Root.displayName
24 |
25 | export { Slider }
26 |
--------------------------------------------------------------------------------
/pages/shop.tsx:
--------------------------------------------------------------------------------
1 | import { useAnalytics } from '~/components/context/analytics'
2 | import SquigglyLines from '../components/SquigglyLines'
3 | import { CHECKOUT_URL, RATE_LIMIT_COUNT } from '~/utils/constants'
4 |
5 | export default () => {
6 | const { analytics } = useAnalytics()
7 |
8 | return (
9 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/utils/extractTimestamp.ts:
--------------------------------------------------------------------------------
1 | export function extractTimestamp(matchResult: RegExpMatchArray) {
2 | let timestamp: string | undefined
3 | const seconds = Number(matchResult[1].replace(':', '.'))
4 | const hours = Math.floor(seconds / 3600)
5 | const remainingSeconds = Math.floor(seconds % 3600)
6 | const minutes = Math.floor(remainingSeconds / 60)
7 | const remainingMinutes = Math.floor(remainingSeconds % 60)
8 | if (hours > 0) {
9 | timestamp = `${hours}:${minutes.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}`
10 | } else {
11 | timestamp = `${minutes}:${remainingMinutes.toString().padStart(2, '0')}`
12 | }
13 |
14 | const content = matchResult[2]
15 | let formattedContent = content
16 | try {
17 | formattedContent = content && /^[::秒]/.test(content) ? content.substring(1) : content
18 | formattedContent = formattedContent && !/^ /.test(formattedContent) ? ' ' + formattedContent : formattedContent
19 | } catch (e) {
20 | console.error('handle text after time error', e)
21 | }
22 | // console.log("========matchResult========", {matchResult, timestamp, formattedContent});
23 | return { timestamp, formattedContent }
24 | }
25 |
26 | export function trimSeconds(secondsStr: number | string) {
27 | return Number(secondsStr).toFixed()
28 | }
29 |
--------------------------------------------------------------------------------
/components/TypingSlogan.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { TypeAnimation } from 'react-type-animation'
3 | import SquigglyLines from '~/components/SquigglyLines'
4 |
5 | export function TypingSlogan() {
6 | return (
7 | <>
8 |
9 | 一键总结{' '}
10 |
11 |
12 | {
25 | console.log('Done typing!') // Place optional callbacks anywhere in the array
26 | },
27 | ]}
28 | wrapper="span"
29 | cursor={true}
30 | repeat={Infinity}
31 | className="relative text-pink-400 "
32 | />
33 | {' '}
34 | 音视频内容
35 |
36 |
37 | Powered by GPT-3.5 AI
38 | >
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/deploy-ch.md:
--------------------------------------------------------------------------------
1 | ## 环境要求
2 |
3 | nodejs 18.0 +
4 |
5 | ## 操作指引
6 |
7 | 1. clone 本仓库
8 | 2. 运行 `npm install`
9 | 3. 复制 [.example.env](.example.env) 到同级目录,并将其改名为 `.env`
10 | 4. 填写 `.env` 文件中所有的必填项 (也就是除了 Optional 下的所有内容)
11 | 1. 在 https://platform.openai.com/account/api-keys 生成 key,复制它并赋值到 OPENAI_API_KEY
12 | 2. 在 https://www.bilibili.com 中使用 F12 打开开发者控制台,导航至 application -> Cookies -> ...www.bilibili.com -> **SESSDATA**,复制该值并赋值到 `BILIBILI_SESSION_TOKEN`
13 | 
14 | 3. 在 https://savesubs.com 中使用 F12 打开开发者控制台,导航至 application -> Cookies -> ...savesubs.com -> **cf_clearance**,复制该值并赋值到 `SAVESUBS_X_AUTH_TOKEN`
15 | 4. 登录 https://upstash.com,在 `Create a Redis Database` 页下点击 `Create database`
16 | 
17 | 根据情况输入基本信息:
18 | 
19 | 进入该数据库的控制台,下滑到 `REST API` 栏,点击复制`UPSTASH_REDIS_REST_URL`和 `UPSTASH_REDIS_REST_TOKEN`,赋值到同名变量
20 | 
21 | 5. 登录 https://supabase.com/ ,新建一个 project
22 | 
23 | 确认后,点击右侧导航的齿轮进入设置,复制 `URL` 到 `SUPABASE_HOSTNAME`,复制 `key` 到 `NEXT_PUBLIC_SUPABASE_ANON_KEY`。
24 | 
25 | 5. 使用 `#` 注释掉 Optional 下的所有项 (可选)
26 | 6. 运行 `npm run dev`
27 |
--------------------------------------------------------------------------------
/hooks/notes/flomo.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { useAnalytics } from '~/components/context/analytics'
3 | import { useToast } from '~/hooks/use-toast'
4 |
5 | export function useSaveToFlomo(note: string, video: string, webhook: string) {
6 | const [loading, setLoading] = useState(false)
7 | const { toast } = useToast()
8 | const { analytics } = useAnalytics()
9 |
10 | const save = async () => {
11 | setLoading(true)
12 | const response = await fetch(webhook, {
13 | method: 'POST',
14 | headers: {
15 | 'Content-Type': 'application/json',
16 | },
17 | body: JSON.stringify({
18 | content: `${note}\n\n原视频:${video}\n#BibiGpt`,
19 | }),
20 | })
21 | const json = await response.json()
22 | console.log('========response========', json)
23 | if (!response.ok || json.code === -1) {
24 | console.log('error', response)
25 | toast({
26 | variant: 'destructive',
27 | title: response.status.toString(),
28 | description: json.message,
29 | })
30 | } else {
31 | toast({
32 | title: response.status.toString(),
33 | description: '保存成功!快去 Flomo 查看吧。',
34 | })
35 | }
36 | setLoading(false)
37 | analytics.track('SaveFlomoButton Clicked')
38 | }
39 | return { save, loading }
40 | }
41 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = ({ ...props }) =>
11 | Tooltip.displayName = TooltipPrimitive.Tooltip.displayName
12 |
13 | const TooltipTrigger = TooltipPrimitive.Trigger
14 |
15 | const TooltipContent = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, sideOffset = 4, ...props }, ref) => (
19 |
28 | ))
29 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
30 |
31 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
32 |
--------------------------------------------------------------------------------
/.github/workflows/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name: Release Drafter
2 |
3 | # ref: https://github.com/marketplace/actions/release-drafter
4 | on:
5 | # push:
6 | # # branches to consider in the event; optional, defaults to all
7 | # branches:
8 | # - main
9 | # pull_request event is required only for autolabeler
10 | pull_request:
11 | # Only following types are handled by the action, but one can default to all as well
12 | types: [opened, reopened, synchronize]
13 | # pull_request_target event is required for autolabeler to support PRs from forks
14 | # pull_request_target:
15 | # types: [opened, reopened, synchronize]
16 |
17 | permissions:
18 | contents: read
19 |
20 | jobs:
21 | update_release_draft:
22 | permissions:
23 | # write permission is required to create a github release
24 | contents: write
25 | # write permission is required for autolabeler
26 | # otherwise, read permission is required at least
27 | pull-requests: write
28 | runs-on: ubuntu-latest
29 | steps:
30 | # Drafts your next Release notes as Pull Requests are merged into "main" branchπ
31 | - uses: release-drafter/release-drafter@v5
32 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml
33 | env:
34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SwitchPrimitives from '@radix-ui/react-switch'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/lib/lemon.ts:
--------------------------------------------------------------------------------
1 | import { isDev } from '~/utils/env'
2 |
3 | export async function activateLicenseKey(licenseKey: string, bvId?: string) {
4 | // not active when dev
5 | if (isDev) {
6 | return true
7 | }
8 |
9 | // https://docs.lemonsqueezy.com/help/licensing/license-api
10 | const response = await fetch(`https://api.lemonsqueezy.com/v1/licenses/activate`, {
11 | method: 'POST',
12 | headers: {
13 | 'Content-Type': 'application/json',
14 | Authorization: `Bearer ${process.env.LEMON_API_KEY ?? ''}`,
15 | },
16 | body: JSON.stringify({
17 | license_key: licenseKey,
18 | instance_name: bvId || 'b.jimmylv.cn',
19 | }),
20 | })
21 | const result = await response.json()
22 | return result.activated
23 | }
24 |
25 | export async function validateLicenseKey(licenseKey: string, bvId?: string) {
26 | // https://docs.lemonsqueezy.com/help/licensing/license-api
27 | const response = await fetch(`https://api.lemonsqueezy.com/v1/licenses/validate`, {
28 | method: 'POST',
29 | headers: {
30 | 'Content-Type': 'application/json',
31 | Authorization: `Bearer ${process.env.LEMON_API_KEY ?? ''}`,
32 | },
33 | body: JSON.stringify({
34 | license_key: licenseKey,
35 | instance_name: bvId || 'b.jimmylv.cn',
36 | }),
37 | })
38 | const result = await response.json()
39 | return result.valid
40 | }
41 |
--------------------------------------------------------------------------------
/components/shared/popover.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, ReactNode, useRef } from 'react'
2 | import * as PopoverPrimitive from '@radix-ui/react-popover'
3 | import useWindowSize from '~/hooks/use-window-size'
4 | import Leaflet from './leaflet'
5 |
6 | export default function Popover({
7 | children,
8 | content,
9 | align = 'center',
10 | openPopover,
11 | setOpenPopover,
12 | }: {
13 | children: ReactNode
14 | content: ReactNode | string
15 | align?: 'center' | 'start' | 'end'
16 | openPopover: boolean
17 | setOpenPopover: Dispatch>
18 | }) {
19 | const { isMobile, isDesktop } = useWindowSize()
20 | return (
21 | <>
22 | {isMobile && children}
23 | {openPopover && isMobile && {content}}
24 | {isDesktop && (
25 |
26 |
27 | {children}
28 |
29 |
34 | {content}
35 |
36 |
37 | )}
38 | >
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/components/context/analytics.tsx:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | // copy from https://github.com/segmentio/analytics-next/blob/master/examples/with-next-js
3 | import React from 'react'
4 | import { AnalyticsBrowser } from '@segment/analytics-next'
5 | import { useCDNUrl, useWriteKey } from '~/hooks/useConfig'
6 | import { isDev } from '~/utils/env'
7 |
8 | const AnalyticsContext = React.createContext<{
9 | analytics: AnalyticsBrowser
10 | writeKey: string
11 | setWriteKey: (key: string) => void
12 | cdnURL: string
13 | setCDNUrl: (url: string) => void
14 | }>(undefined)
15 |
16 | export const AnalyticsProvider: React.FC<{ children: React.ReactElement }> = ({ children }) => {
17 | const [writeKey, setWriteKey] = useWriteKey()
18 | const [cdnURL, setCDNUrl] = useCDNUrl()
19 |
20 | const analytics = React.useMemo(() => {
21 | isDev && console.log(`AnalyticsBrowser loading...`, JSON.stringify({ writeKey, cdnURL }))
22 | return AnalyticsBrowser.load({ writeKey, cdnURL })
23 | }, [writeKey, cdnURL])
24 | return (
25 |
26 | {children}
27 |
28 | )
29 | }
30 |
31 | // Create an analytics hook that we can use with other components.
32 | export const useAnalytics = () => {
33 | const result = React.useContext(AnalyticsContext)
34 | if (!result) {
35 | throw new Error('Context used outside of its Provider!')
36 | }
37 | return result
38 | }
39 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from 'next/document'
2 | import { BASE_DOMAIN } from '~/utils/constants'
3 |
4 | class MyDocument extends Document {
5 | render() {
6 | let description = 'B 站视频内容一键总结(支持 iOS 快捷指令)'
7 | let ogimage = `${BASE_DOMAIN}/og-image.png`
8 | let sitename = 'b.jimmylv.cn'
9 | let title = '哔哩哔哩 · 视频内容一键总结'
10 |
11 | return (
12 |
13 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 | }
36 |
37 | export default MyDocument
38 |
--------------------------------------------------------------------------------
/lib/youtube/fetchYoutubeSubtitle.ts:
--------------------------------------------------------------------------------
1 | import { fetchYoutubeSubtitleUrls, SUBTITLE_DOWNLOADER_URL } from '~/lib/youtube/fetchYoutubeSubtitleUrls'
2 | import { find } from '~/utils/fp'
3 | import { reduceYoutubeSubtitleTimestamp } from '~/utils/reduceSubtitleTimestamp'
4 |
5 | export async function fetchYoutubeSubtitle(videoId: string, shouldShowTimestamp: boolean | undefined) {
6 | const { title, subtitleList } = await fetchYoutubeSubtitleUrls(videoId)
7 | if (!subtitleList || subtitleList?.length <= 0) {
8 | return { title, subtitlesArray: null }
9 | }
10 | const betterSubtitle =
11 | find(subtitleList, { quality: 'zh-CN' }) ||
12 | find(subtitleList, { quality: 'English' }) ||
13 | find(subtitleList, { quality: 'English (auto' }) ||
14 | subtitleList[0]
15 | if (shouldShowTimestamp) {
16 | const subtitleUrl = `${SUBTITLE_DOWNLOADER_URL}${betterSubtitle.url}?ext=json`
17 | const response = await fetch(subtitleUrl)
18 | const subtitles = await response.json()
19 | // console.log("========youtube subtitles========", subtitles);
20 | const transcripts = reduceYoutubeSubtitleTimestamp(subtitles)
21 | return { title, subtitlesArray: transcripts }
22 | }
23 |
24 | const subtitleUrl = `${SUBTITLE_DOWNLOADER_URL}${betterSubtitle.url}?ext=txt`
25 | const response = await fetch(subtitleUrl)
26 | const subtitles = await response.text()
27 | const transcripts = subtitles.split('\r\n\r\n')?.map((text: string, index: number) => ({ text, index }))
28 | return { title, subtitlesArray: transcripts }
29 | }
30 |
--------------------------------------------------------------------------------
/components/SignIn.tsx:
--------------------------------------------------------------------------------
1 | import { useUser } from '@supabase/auth-helpers-react'
2 | import { AnimatePresence, motion } from 'framer-motion'
3 | import { useAnalytics } from '~/components/context/analytics'
4 | import UserDropdown from '~/components/user-dropdown'
5 | import { FADE_IN_ANIMATION_SETTINGS } from '~/utils/constants'
6 |
7 | export default function SignIn({ showSingIn }: { showSingIn: (show: boolean) => void }) {
8 | const user = useUser()
9 | const { analytics } = useAnalytics()
10 |
11 | if (user) {
12 | analytics.identify(user.id, {
13 | email: user.email,
14 | })
15 | }
16 |
17 | function handleSignIn() {
18 | showSingIn(true)
19 | analytics.track('SignInButton Clicked')
20 | }
21 |
22 | /*useEffect(() => {
23 | async function loadData() {
24 | const { data } = await supabaseClient.from('test').select('*')
25 | setData(data)
26 | }
27 | // Only run query once user is logged in.
28 | if (user) loadData()
29 | }, [user])*/
30 | return (
31 |
32 |
33 | {user ? (
34 |
35 | ) : (
36 |
41 | 登录
42 |
43 | )}
44 |
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | },
5 | "redirects": [
6 | {
7 | "source": "/ios",
8 | "destination": "https://www.icloud.com/shortcuts/7ea55296c4c04b4c9ad7ccb6142df57f",
9 | "permanent": true
10 | },
11 | {
12 | "source": "/f",
13 | "destination": "https://jimmylv.feishu.cn/share/base/form/shrcn9PwPzGGGiJCnH0JNfM1P3b",
14 | "permanent": true
15 | },
16 | {
17 | "source": "/f-g",
18 | "destination": "https://github.com/JimmyLv/BibiGPT/issues",
19 | "permanent": true
20 | },
21 | {
22 | "source": "/f-v",
23 | "destination": "https://vercel.com/jimmylv/chat-bilibili-video/analytics/audience",
24 | "permanent": true
25 | },
26 | {
27 | "source": "/f-r",
28 | "destination": "https://console.upstash.com/redis/c73974bc-9629-486f-b66e-1031566617fb?tab=usage",
29 | "permanent": true
30 | },
31 | {
32 | "source": "/privacy",
33 | "destination": "https://github.com/JimmyLv/BibiGPT/wiki/%E9%9A%90%E7%A7%81%E5%A3%B0%E6%98%8E",
34 | "permanent": true
35 | },
36 | {
37 | "source": "/terms-of-use",
38 | "destination": "https://github.com/JimmyLv/BibiGPT/wiki/Terms-Of-Use",
39 | "permanent": true
40 | },
41 | {
42 | "source": "/status",
43 | "destination": "https://github.com/JimmyLv/BibiGPT",
44 | "permanent": true
45 | },
46 | {
47 | "source": "/release",
48 | "destination": "https://github.com/JimmyLv/BibiGPT/releases",
49 | "permanent": true
50 | }
51 | ]
52 | }
53 |
--------------------------------------------------------------------------------
/components/Sentence.tsx:
--------------------------------------------------------------------------------
1 | import { extractSentenceWithTimestamp } from '~/utils/extractSentenceWithTimestamp'
2 | import { extractTimestamp, trimSeconds } from '~/utils/extractTimestamp'
3 |
4 | export default function videoIdSentence({
5 | videoId,
6 | videoUrl,
7 | sentence,
8 | }: {
9 | videoId: string
10 | videoUrl: string
11 | sentence: string
12 | }) {
13 | // https://youtube.com/watch?v=DHhOgWPKIKU&t=15s
14 | // https://youtu.be/DHhOgWPKIKU?t=246
15 | // https://www.bilibili.com/video/BV1fX4y1Q7Ux/?t=10
16 |
17 | const isBiliBili = videoUrl.includes('bilibili.com')
18 | // todo: if videoUrl is short-url (not bilibili.com or youtube.com)
19 | const baseUrl = isBiliBili
20 | ? `https://www.bilibili.com/video/${videoId}/?t=`
21 | : `https://youtube.com/watch?v=${videoId}&t=`
22 |
23 | const matchResult = extractSentenceWithTimestamp(sentence)
24 | if (matchResult) {
25 | // simplify the seconds with number: 1:11 or 1.11 -> 7, todo: 0.003 is not able
26 | const secondsStr = matchResult[1].split(':')[0]
27 | const seconds = trimSeconds(secondsStr)
28 | const { formattedContent, timestamp } = extractTimestamp(matchResult)
29 |
30 | return (
31 |
32 |
38 | {timestamp}
39 |
40 | {`${formattedContent}`}
41 |
42 | )
43 | }
44 | return {sentence}
45 | }
46 |
--------------------------------------------------------------------------------
/utils/formatSummary.ts:
--------------------------------------------------------------------------------
1 | import { extractSentenceWithTimestamp } from '~/utils/extractSentenceWithTimestamp'
2 | import { extractTimestamp } from '~/utils/extractTimestamp'
3 |
4 | /**
5 | * a summary generated by ChatGPT
6 | *
7 | * @export
8 | * @param {string} summary
9 | * @return {*} {{
10 | * summaryArray: string[],
11 | * formattedSummary: string
12 | * }}
13 | */
14 | export function formatSummary(summary: string): {
15 | summaryArray: string[]
16 | formattedSummary: string
17 | } {
18 | /*
19 | if (shouldShowTimestamp) {
20 | try {
21 | const parsedBulletPoints = JSON.parse(summary);
22 | const summaryArray = parsedBulletPoints.map(
23 | ({ s, bullet_point }: { s: number; bullet_point: string }) => {
24 | const startTime = s === 0 ? "0.0" : s;
25 | return startTime + " " + bullet_point;
26 | }
27 | );
28 | return {
29 | summaryArray,
30 | formattedSummary: summaryArray.join("\n"),
31 | };
32 | } catch (e) {
33 | console.error(e);
34 | return {};
35 | }
36 | }
37 | */
38 |
39 | const summaryArray: string[] = summary.split('\n- ')
40 | const formattedSummary: string = summaryArray
41 | .map((s) => {
42 | const matchTimestampResult = extractSentenceWithTimestamp(s)
43 | if (matchTimestampResult) {
44 | const { formattedContent, timestamp } = extractTimestamp(matchTimestampResult)
45 | return timestamp + formattedContent
46 | } else {
47 | return s.replace(/\n\n/g, '\n')
48 | }
49 | })
50 | .join('\n- ')
51 | return { summaryArray, formattedSummary }
52 | }
53 |
--------------------------------------------------------------------------------
/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { useTheme } from 'next-themes'
5 |
6 | import { Icons } from '@/components/icons'
7 | import { Button } from './ui/button'
8 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'
9 |
10 | export function ModeToggle() {
11 | const { setTheme } = useTheme()
12 |
13 | return (
14 |
15 |
16 |
21 |
22 |
23 | setTheme('light')}>
24 |
25 | 明亮
26 |
27 | setTheme('dark')}>
28 |
29 | 黑暗
30 |
31 | setTheme('system')}>
32 |
33 | 系统
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/pages/_error.tsx:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | /**
3 | * NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
4 | *
5 | * NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
6 | * penultimate line in `CustomErrorComponent`.
7 | *
8 | * This page is loaded by Nextjs:
9 | * - on the server, when data-fetching methods throw or reject
10 | * - on the client, when `getInitialProps` throws or rejects
11 | * - on the client, when a React lifecycle method throws or rejects, and it's
12 | * caught by the built-in Nextjs error boundary
13 | *
14 | * See:
15 | * - https://nextjs.org/docs/basic-features/data-fetching/overview
16 | * - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
17 | * - https://reactjs.org/docs/error-boundaries.html
18 | */
19 |
20 | import * as Sentry from '@sentry/nextjs'
21 | import NextErrorComponent from 'next/error'
22 |
23 | const CustomErrorComponent = (props) => {
24 | // If you're using a Nextjs version prior to 12.2.1, uncomment this to
25 | // compensate for https://github.com/vercel/next.js/issues/8592
26 | // Sentry.captureUnderscoreErrorException(props);
27 |
28 | return
29 | }
30 |
31 | CustomErrorComponent.getInitialProps = async (contextData) => {
32 | // In case this is running in a serverless function, await this in order to give Sentry
33 | // time to send the error before the lambda exits
34 | await Sentry.captureUnderscoreErrorException(contextData)
35 |
36 | // This will contain the status code of the response
37 | return NextErrorComponent.getInitialProps(contextData)
38 | }
39 |
40 | export default CustomErrorComponent
41 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Hi, here is a Docker build file for your convenience in building a private Docker image.
2 | # Please make sure to configure the .env file in this project's root directory before proceeding.
3 | # It is important to note that this Docker image will include your .env file, so do not publicly share your Docker image.
4 |
5 | # Please follow the steps below:
6 | # 1. Install Docker
7 | # 2. Configure .env file
8 | # 3. Build Docker image
9 |
10 | # > Step 1 build NextJs
11 | FROM node:alpine AS builder
12 | WORKDIR /app
13 | COPY . .
14 |
15 | # removing sentry. If you want to use sentry, please set IS_USED_SENTRY=1
16 | ARG IS_USED_SENTRY=0
17 | RUN if [[ $IS_USED_SENTRY -eq 0 ]]; then \
18 | sed -i 's/const { withSentryConfig }/\/\/ const { withSentryConfig }/' ./next.config.js &&\
19 | sed -i 's/module.exports = withSentryConfig/\/\/ module.exports = withSentryConfig/' ./next.config.js \
20 | ; fi
21 | # building Nextjs
22 | RUN npm ci && npm run build
23 |
24 |
25 | # > Step 2 Build docker image
26 | FROM node:alpine AS runner
27 | WORKDIR /app
28 |
29 | ENV NODE_ENV production
30 | ENV PORT 3000
31 |
32 | RUN addgroup -g 1001 -S nodejs &&\
33 | adduser -S nextjs -u 1001
34 |
35 | COPY --from=builder /app/.env ./.env
36 | COPY --from=builder /app/next.config.js ./
37 | COPY --from=builder /app/public ./public
38 | COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
39 | COPY --from=builder /app/node_modules ./node_modules
40 | COPY --from=builder /app/package.json ./package.json
41 | COPY --from=builder /app/README.md ./README.md
42 | COPY --from=builder /app/LICENSE.txt ./LICENSE.txt
43 |
44 | USER nextjs
45 | EXPOSE 3000
46 | CMD ["node_modules/.bin/next", "start"]
--------------------------------------------------------------------------------
/lib/bilibili/fetchBilibiliSubtitle.ts:
--------------------------------------------------------------------------------
1 | import { reduceBilibiliSubtitleTimestamp } from '~/utils/reduceSubtitleTimestamp'
2 | import { fetchBilibiliSubtitleUrls } from './fetchBilibiliSubtitleUrls'
3 |
4 | export async function fetchBilibiliSubtitle(
5 | videoId: string,
6 | pageNumber?: null | string,
7 | shouldShowTimestamp?: boolean,
8 | ) {
9 | const res = await fetchBilibiliSubtitleUrls(videoId, pageNumber)
10 | const { title, desc, dynamic, subtitle } = res || {}
11 | const hasDescription = desc || dynamic
12 | const descriptionText = hasDescription ? `${desc} ${dynamic}` : undefined
13 | const subtitleList = subtitle?.list
14 | if (!subtitleList || subtitleList?.length < 1) {
15 | return { title, subtitlesArray: null, descriptionText }
16 | }
17 |
18 | const betterSubtitle = subtitleList.find(({ lan }: { lan: string }) => lan === 'zh-CN') || subtitleList[0]
19 | const subtitleUrl = betterSubtitle?.subtitle_url?.startsWith('//')
20 | ? `https:${betterSubtitle?.subtitle_url}`
21 | : betterSubtitle?.subtitle_url
22 | console.log('subtitle_url', subtitleUrl)
23 |
24 | const subtitleResponse = await fetch(subtitleUrl)
25 | const subtitles = await subtitleResponse.json()
26 | const transcripts = reduceBilibiliSubtitleTimestamp(subtitles?.body, shouldShowTimestamp)
27 | return { title, subtitlesArray: transcripts, descriptionText }
28 | }
29 |
30 | // const res = await pRetry(async () => await fetchBilibiliSubtitles(videoId), {
31 | // onFailedAttempt: (error) => {
32 | // console.log(
33 | // `Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left.`
34 | // );
35 | // },
36 | // retries: 2,
37 | // });
38 | // @ts-ignore
39 |
--------------------------------------------------------------------------------
/hooks/useLocalStorage.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 |
4 | /**
5 | * State to store our value
6 | * Pass initial state function to useState so logic is only executed once
7 | *
8 | * @export
9 | * @template T
10 | * @param {string} key
11 | * @param {T} [initialValue]
12 | * @return {*} {(T | undefined)}
13 | */
14 | export function useLocalStorage(key: string, initialValue?: T){
15 | const [storedValue, setStoredValue] = React.useState(() => {
16 | if (typeof window === 'undefined') {
17 | return initialValue
18 | }
19 | try {
20 | // Get from local storage by key
21 | const item = window.localStorage.getItem(key)
22 | // Parse stored json or if none return initialValue
23 | return item ? JSON.parse(item) : initialValue
24 | } catch (error) {
25 | // If error also return initialValue
26 | console.error(error)
27 | return initialValue
28 | }
29 | })
30 |
31 |
32 | /**
33 | * Return a wrapped version of useState's setter function that ...
34 | * ...persists the new value to localStorage.
35 | *
36 | * @param {T} value
37 | */
38 | const setValue = (value: T): void => {
39 | try {
40 | // Allow value to be a function so we have same API as useState
41 | const valueToStore = value instanceof Function ? value(storedValue) : value
42 | // Save state
43 | setStoredValue(valueToStore)
44 | // Save to local storage
45 | if (typeof window !== 'undefined') {
46 | window.localStorage.setItem(key, JSON.stringify(valueToStore))
47 | }
48 | } catch (error) {
49 | // A more advanced implementation would handle the error case
50 | console.log(error)
51 | }
52 | }
53 | return [storedValue, setValue] as const
54 | }
55 |
--------------------------------------------------------------------------------
/utils/reduceSubtitleTimestamp.ts:
--------------------------------------------------------------------------------
1 | import { CommonSubtitleItem } from '~/lib/types'
2 |
3 | export type YoutubeSubtitleItem = { start: number; lines: string[] }
4 | /*{ "from": 16.669, "content": "让ppt变得更加精彩" },*/
5 | export type BilibiliSubtitleItem = { from: number; content: string }
6 |
7 | export function reduceYoutubeSubtitleTimestamp(subtitles: Array = []) {
8 | return reduceSubtitleTimestamp(
9 | subtitles,
10 | (i) => i.start,
11 | (i) => i.lines.join(' '),
12 | true,
13 | )
14 | }
15 |
16 | export function reduceBilibiliSubtitleTimestamp(
17 | subtitles: Array = [],
18 | shouldShowTimestamp?: boolean,
19 | ): Array {
20 | return reduceSubtitleTimestamp(
21 | subtitles,
22 | (i) => i.from,
23 | (i) => i.content,
24 | shouldShowTimestamp,
25 | )
26 | }
27 | export function reduceSubtitleTimestamp(
28 | subtitles: Array = [],
29 | getStart: (i: T) => number,
30 | getText: (i: T) => string,
31 | shouldShowTimestamp?: boolean,
32 | ): Array {
33 | // 把字幕数组总共分成 20 组
34 | const TOTAL_GROUP_COUNT = 30
35 | // 如果字幕不够多,就每7句话合并一下
36 | const MINIMUM_COUNT_ONE_GROUP = 7
37 | const eachGroupCount =
38 | subtitles.length > TOTAL_GROUP_COUNT ? subtitles.length / TOTAL_GROUP_COUNT : MINIMUM_COUNT_ONE_GROUP
39 |
40 | return subtitles.reduce((accumulator: CommonSubtitleItem[], current: T, index: number) => {
41 | // 计算当前元素在哪一组
42 | const groupIndex: number = Math.floor(index / MINIMUM_COUNT_ONE_GROUP)
43 |
44 | // 如果是当前组的第一个元素,初始化这一组的字符串
45 | if (!accumulator[groupIndex]) {
46 | accumulator[groupIndex] = {
47 | // 5.88 -> 5.9
48 | // text: current.start.toFixed() + ": ",
49 | index: groupIndex,
50 | s: getStart(current),
51 | text: shouldShowTimestamp ? getStart(current) + ' - ' : '',
52 | }
53 | }
54 |
55 | // 将当前元素添加到当前组的字符串末尾
56 | accumulator[groupIndex].text = accumulator[groupIndex].text + getText(current) + ' '
57 |
58 | return accumulator
59 | }, [])
60 | }
61 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import { VariantProps, cva } from 'class-variance-authority'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | const buttonVariants = cva(
7 | 'active:scale-95 inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800',
8 | {
9 | variants: {
10 | variant: {
11 | default: 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900',
12 | destructive: 'bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600',
13 | outline: 'bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100',
14 | subtle: 'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100',
15 | ghost:
16 | 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent',
17 | link: 'bg-transparent dark:bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent',
18 | },
19 | size: {
20 | default: 'h-10 py-2 px-4',
21 | sm: 'h-9 px-2 rounded-md',
22 | lg: 'h-11 px-8 rounded-md',
23 | },
24 | },
25 | defaultVariants: {
26 | variant: 'default',
27 | size: 'default',
28 | },
29 | },
30 | )
31 |
32 | export interface ButtonProps
33 | extends React.ButtonHTMLAttributes,
34 | VariantProps {}
35 |
36 | const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => {
37 | return
38 | })
39 | Button.displayName = 'Button'
40 |
41 | export { Button, buttonVariants }
42 |
--------------------------------------------------------------------------------
/lib/bilibili/fetchBilibiliSubtitleUrls.ts:
--------------------------------------------------------------------------------
1 | import { find, sample } from '~/utils/fp'
2 |
3 | type BilibiliSubtitles = {
4 | lan: string
5 | subtitle_url: string
6 | }
7 |
8 | interface BilibiliVideoInfo {
9 | title: string
10 | desc?: string
11 | dynamic?: string
12 | subtitle?: {
13 | list: BilibiliSubtitles[]
14 | }
15 | }
16 | export const fetchBilibiliSubtitleUrls = async (
17 | videoId: string,
18 | pageNumber?: null | string,
19 | ): Promise => {
20 | const sessdata = sample(process.env.BILIBILI_SESSION_TOKEN?.split(','))
21 | const headers = {
22 | Accept: 'application/json',
23 | 'Content-Type': 'application/json',
24 | 'User-Agent':
25 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
26 | Host: 'api.bilibili.com',
27 | Cookie: `SESSDATA=${sessdata}`,
28 | }
29 | const commonConfig: RequestInit = {
30 | method: 'GET',
31 | cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
32 | headers,
33 | referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
34 | }
35 |
36 | const params = videoId.startsWith('av') ? `?aid=${videoId.slice(2)}` : `?bvid=${videoId}`
37 | const requestUrl = `https://api.bilibili.com/x/web-interface/view${params}`
38 | console.log(`fetch`, requestUrl)
39 | const response = await fetch(requestUrl, commonConfig)
40 | const json = await response.json()
41 |
42 | // support multiple parts of video
43 | if (pageNumber || json?.data?.pages?.length > 0) {
44 | const { aid, pages } = json?.data || {}
45 | const { cid } = find(pages, { page: Number(pageNumber || 1) }) || {}
46 |
47 | // https://api.bilibili.com/x/player/v2?aid=865462240&cid=1035524244
48 | const pageUrl = `https://api.bilibili.com/x/player/v2?aid=${aid}&cid=${cid}`
49 | const res = await fetch(pageUrl, commonConfig)
50 | const j = await res.json()
51 |
52 | // r.data.subtitle.subtitles
53 | return { ...json.data, subtitle: { list: j.data.subtitle.subtitles } }
54 | }
55 |
56 | // return json.data.View;
57 | // { code: -404, message: '啥都木有', ttl: 1 }
58 | return json.data
59 | }
60 |
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import { ModeToggle } from '~/components/mode-toggle'
3 | import { Icons } from './icons'
4 | import { buttonVariants } from '@/components/ui/button'
5 |
6 | export default function Footer() {
7 | return (
8 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/components/shared/modal.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from 'react'
2 | import FocusTrap from 'focus-trap-react'
3 | import { AnimatePresence, motion } from 'framer-motion'
4 | import Leaflet from './leaflet'
5 | import useWindowSize from '~/hooks/use-window-size'
6 |
7 | export default function Modal({
8 | children,
9 | showModal,
10 | setShowModal,
11 | }: {
12 | children: React.ReactNode
13 | showModal: boolean
14 | setShowModal: Dispatch>
15 | }) {
16 | const desktopModalRef = useRef(null)
17 |
18 | const onKeyDown = useCallback(
19 | (e: KeyboardEvent) => {
20 | if (e.key === 'Escape') {
21 | setShowModal(false)
22 | }
23 | },
24 | [setShowModal],
25 | )
26 |
27 | useEffect(() => {
28 | document.addEventListener('keydown', onKeyDown)
29 | return () => document.removeEventListener('keydown', onKeyDown)
30 | }, [onKeyDown])
31 |
32 | const { isMobile, isDesktop } = useWindowSize()
33 |
34 | return (
35 |
36 | {showModal && (
37 | <>
38 | {isMobile && {children}}
39 | {isDesktop && (
40 | <>
41 |
42 | {
50 | if (desktopModalRef.current === e.target) {
51 | setShowModal(false)
52 | }
53 | }}
54 | >
55 | {children}
56 |
57 |
58 | setShowModal(false)}
65 | />
66 | >
67 | )}
68 | >
69 | )}
70 |
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/hooks/notes/lark.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { useAnalytics } from '~/components/context/analytics'
3 | import { useToast } from '~/hooks/use-toast'
4 |
5 | export default function useSaveToLark(note: string, video: string, webhook: string) {
6 | const [loading, setLoading] = useState(false)
7 | const { toast } = useToast()
8 | const { analytics } = useAnalytics()
9 |
10 | const save = async () => {
11 | const larkCardData = {
12 | msg_type: 'interactive',
13 | card: {
14 | elements: [
15 | {
16 | tag: 'div',
17 | text: {
18 | content: note,
19 | tag: 'plain_text',
20 | },
21 | },
22 | {
23 | tag: 'note',
24 | elements: [
25 | {
26 | tag: 'plain_text',
27 | content: `原视频:${video}`,
28 | },
29 | ],
30 | },
31 | {
32 | tag: 'action',
33 | actions: [
34 | {
35 | tag: 'button',
36 | text: {
37 | tag: 'plain_text',
38 | content: '观看视频',
39 | },
40 | type: 'primary',
41 | multi_url: {
42 | url: video,
43 | },
44 | },
45 | ],
46 | },
47 | ],
48 | header: {
49 | template: 'blue',
50 | title: {
51 | content: 'BibiGPT 视频摘要',
52 | tag: 'plain_text',
53 | },
54 | },
55 | },
56 | }
57 | setLoading(true)
58 | console.log(note)
59 | const response = await fetch(webhook, {
60 | method: 'POST',
61 | headers: {
62 | 'Content-Type': 'application/json',
63 | },
64 | body: JSON.stringify(larkCardData),
65 | })
66 | const json = await response.json()
67 | console.log('========response========', json)
68 | if (!response.ok || json.code !== 0) {
69 | console.log('error', response)
70 | toast({
71 | variant: 'destructive',
72 | title: response.status.toString(),
73 | description: json.msg,
74 | })
75 | } else {
76 | toast({
77 | title: response.status.toString(),
78 | description: '成功推送到 飞书/Lark Webhook',
79 | })
80 | }
81 | setLoading(false)
82 | analytics.track('SaveLarkButton Clicked')
83 | }
84 | return { save, loading }
85 | }
86 |
--------------------------------------------------------------------------------
/components/shared/leaflet.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, ReactNode, Dispatch, SetStateAction } from 'react'
2 | import { AnimatePresence, motion, useAnimation } from 'framer-motion'
3 |
4 | export default function Leaflet({
5 | setShow,
6 | children,
7 | }: {
8 | setShow: Dispatch>
9 | children: ReactNode
10 | }) {
11 | const leafletRef = useRef(null)
12 | const controls = useAnimation()
13 | const transitionProps = { type: 'spring', stiffness: 500, damping: 30 }
14 | useEffect(() => {
15 | controls.start({
16 | y: 20,
17 | transition: transitionProps,
18 | })
19 | // eslint-disable-next-line react-hooks/exhaustive-deps
20 | }, [])
21 |
22 | async function handleDragEnd(_: any, info: any) {
23 | const offset = info.offset.y
24 | const velocity = info.velocity.y
25 | const height = leafletRef.current?.getBoundingClientRect().height || 0
26 | if (offset > height / 2 || velocity > 800) {
27 | await controls.start({ y: '100%', transition: transitionProps })
28 | setShow(false)
29 | } else {
30 | controls.start({ y: 0, transition: transitionProps })
31 | }
32 | }
33 |
34 | return (
35 |
36 |
50 |
54 | {children}
55 |
56 | setShow(false)}
63 | />
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { Inter as FontSans } from '@next/font/google'
2 | import { createBrowserSupabaseClient, Session } from '@supabase/auth-helpers-nextjs'
3 | import { SessionContextProvider } from '@supabase/auth-helpers-react'
4 | import { Analytics } from '@vercel/analytics/react'
5 | import { ThemeProvider } from 'next-themes'
6 | import type { AppProps } from 'next/app'
7 | import React, { useState } from 'react'
8 | import CommandMenu from '~/components/CommandMenu'
9 | import { AnalyticsProvider } from '~/components/context/analytics'
10 | import { useSignInModal } from '~/components/sign-in-modal'
11 | import { TailwindIndicator } from '~/components/tailwind-indicator'
12 | import { Toaster } from '~/components/ui/toaster'
13 | import { TooltipProvider } from '~/components/ui/tooltip'
14 | import { cn } from '~/lib/utils'
15 | import Footer from '../components/Footer'
16 | import Header from '../components/Header'
17 | import '../styles/globals.css'
18 | import '../styles/markdown.css'
19 |
20 | const fontSans = FontSans({
21 | subsets: ['latin'],
22 | variable: '--font-sans',
23 | display: 'swap',
24 | })
25 | function MyApp({
26 | Component,
27 | pageProps,
28 | }: AppProps<{
29 | initialSession: Session
30 | }>) {
31 | // Create a new supabase browser client on every first render.
32 | const [supabaseClient] = useState(() => createBrowserSupabaseClient())
33 | const { SignInModal, setShowSignInModal: showSingIn } = useSignInModal()
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
59 | export default MyApp
60 |
--------------------------------------------------------------------------------
/components/ActionsAfterResult.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 | import React from 'react'
3 | import { SaveNoteButton } from '~/components/SaveNoteButton'
4 | import { useSaveToFlomo } from '~/hooks/notes/flomo'
5 | import useSaveToLark from '~/hooks/notes/lark'
6 | import { useLocalStorage } from '~/hooks/useLocalStorage'
7 |
8 | export function ActionsAfterResult({
9 | curVideo,
10 | onCopy,
11 | summaryNote,
12 | }: {
13 | curVideo: string
14 | summaryNote: string
15 | onCopy: () => void
16 | }) {
17 | const [flomoWebhook] = useLocalStorage('user-flomo-webhook')
18 | const [larkWebhook] = useLocalStorage('user-lark-webhook')
19 | const { loading: flomoLoading, save: flomoSave } = useSaveToFlomo(summaryNote, curVideo, flomoWebhook || '')
20 | const { loading: larkLoading, save: larkSave } = useSaveToLark(summaryNote, curVideo, larkWebhook || '')
21 | const hasNoteSetting = flomoWebhook || larkWebhook
22 |
23 | return (
24 |
25 |
31 | (关注我 😛)
32 |
33 |
39 | 回到视频
40 |
41 |
47 | {!hasNoteSetting ? (
48 |
53 | 📒 一键保存到笔记
54 |
55 | ) : (
56 | <>
57 | {flomoWebhook &&
}
58 | {larkWebhook &&
}
59 | >
60 | )}
61 |
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/components/SummaryResult.tsx:
--------------------------------------------------------------------------------
1 | import Markdown from 'marked-react'
2 | import React from 'react'
3 | import { ActionsAfterResult } from '~/components/ActionsAfterResult'
4 | import Sentence from '~/components/Sentence'
5 | import { useToast } from '~/hooks/use-toast'
6 | import { formatSummary } from '~/utils/formatSummary'
7 |
8 | export let isSecureContext = false
9 |
10 | if (typeof window !== 'undefined') {
11 | isSecureContext = window.isSecureContext
12 | }
13 |
14 | export function SummaryResult({
15 | currentVideoUrl,
16 | currentVideoId,
17 | summary,
18 | shouldShowTimestamp,
19 | }: {
20 | currentVideoUrl: string
21 | currentVideoId: string
22 | summary: string
23 | shouldShowTimestamp?: boolean
24 | }) {
25 | const { toast } = useToast()
26 | const formattedCachedSummary = summary?.startsWith('"')
27 | ? summary
28 | .substring(1, summary.length - 1)
29 | .split('\\n')
30 | .join('\n')
31 | : summary
32 |
33 | const { summaryArray, formattedSummary } = formatSummary(formattedCachedSummary)
34 | const summaryNote = formattedSummary + '\n\n#BibiGPT https://b.jimmylv.cn @吕立青_JimmyLv \nBV1fX4y1Q7Ux'
35 |
36 | const handleCopy = () => {
37 | if (!isSecureContext) {
38 | toast({ description: '复制错误 ❌' })
39 | return
40 | }
41 | navigator.clipboard.writeText(summaryNote)
42 | toast({ description: '复制成功 ✂️' })
43 | }
44 |
45 | return (
46 |
47 |
52 |
53 |
54 | {shouldShowTimestamp ? (
55 | summaryArray.map((sentence: string, index: number) => (
56 |
57 | {sentence.length > 0 && (
58 |
59 | )}
60 |
61 | ))
62 | ) : (
63 |
64 | {formattedCachedSummary}
65 |
66 | )}
67 |
68 |
69 |
70 | )
71 | }
72 |
--------------------------------------------------------------------------------
/components/UserKeyInput.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useAnalytics } from '~/components/context/analytics'
3 | import { CHECKOUT_URL, RATE_LIMIT_COUNT } from '~/utils/constants'
4 |
5 | export function UserKeyInput(props: { value: string | undefined; onChange: (e: any) => void }) {
6 | const { analytics } = useAnalytics()
7 |
8 | return (
9 |
10 |
11 |
25 |
26 | 请使用自己的 API Key
27 | (每天免费 {RATE_LIMIT_COUNT} 次哦,支持
28 | analytics.track('ShopLink Clicked')}
34 | >
35 | 「购买次数」
36 |
37 | 啦!
38 |
39 | 也可以真的
40 | 「给我打赏」哦 🤣)
41 |
42 |
43 |
44 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/pages/user/dashboard.tsx:
--------------------------------------------------------------------------------
1 | import { Sidebar } from '~/components/sidebar'
2 |
3 | export default () => {
4 | return (
5 | <>
6 |
7 |
8 |
9 |
20 |
23 |
31 |
34 |
48 |
49 |
50 | >
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "bibi-gpt",
4 | "scripts": {
5 | "dev": "next dev",
6 | "build": "next build",
7 | "start": "next start",
8 | "prepare": "husky install"
9 | },
10 | "lint-staged": {
11 | "**/*": "prettier --write --ignore-unknown"
12 | },
13 | "repository": "https://github.com/JimmyLv/BibiGPT.git",
14 | "dependencies": {
15 | "@hookform/resolvers": "^2.9.11",
16 | "@next/font": "^13.1.5",
17 | "@radix-ui/react-dialog": "^1.0.2",
18 | "@radix-ui/react-label": "^2.0.0",
19 | "@radix-ui/react-popover": "^1.0.4",
20 | "@radix-ui/react-select": "^1.2.1",
21 | "@radix-ui/react-slider": "^1.1.1",
22 | "@radix-ui/react-switch": "^1.0.1",
23 | "@radix-ui/react-toast": "^1.1.2",
24 | "@radix-ui/react-tooltip": "^1.0.4",
25 | "@segment/analytics-next": "^1.51.1",
26 | "@sentry/nextjs": "^7.40.0",
27 | "@supabase/auth-helpers-nextjs": "^0.5.5",
28 | "@supabase/auth-ui-react": "^0.3.3",
29 | "@supabase/auth-ui-shared": "^0.1.2",
30 | "@supabase/supabase-js": "^2.10.0",
31 | "@teovilla/shadcn-ui-react": "^0.5.0",
32 | "@upstash/ratelimit": "^0.3.8",
33 | "@upstash/redis": "^1.20.1",
34 | "@vercel/analytics": "^0.1.8",
35 | "class-variance-authority": "^0.4.0",
36 | "clsx": "^1.2.1",
37 | "cmdk": "^0.2.0",
38 | "csstype": "^3.1.1",
39 | "eventsource-parser": "^0.1.0",
40 | "flowbite": "^1.6.3",
41 | "flowbite-react": "^0.4.1",
42 | "focus-trap-react": "^10.1.0",
43 | "framer-motion": "^9.0.1",
44 | "get-video-id": "^3.6.5",
45 | "lemonsqueezy.ts": "^0.1.6",
46 | "lucide-react": "^0.122.0",
47 | "marked-react": "^1.3.2",
48 | "next": "latest",
49 | "next-themes": "^0.2.1",
50 | "node-html-parser": "^6.1.4",
51 | "p-retry": "^5.1.2",
52 | "prop-types": "^15.8.1",
53 | "react": "18.2.0",
54 | "react-bilibili-embed-renderer": "^1.2.1",
55 | "react-dom": "18.2.0",
56 | "react-hook-form": "^7.43.5",
57 | "react-hook-form-persist": "^3.0.0",
58 | "react-hot-toast": "^2.4.0",
59 | "react-type-animation": "^2.1.2",
60 | "react-use": "^17.4.0",
61 | "react-use-measure": "^2.1.1",
62 | "tailwind-merge": "^1.10.0",
63 | "tailwindcss-animate": "^1.0.5",
64 | "zod": "^3.21.4"
65 | },
66 | "devDependencies": {
67 | "@semantic-release/changelog": "^6.0.2",
68 | "@semantic-release/git": "^10.0.1",
69 | "@supabase/auth-helpers-react": "^0.3.1",
70 | "@types/node": "18.11.3",
71 | "@types/react": "18.0.21",
72 | "@types/react-dom": "18.0.6",
73 | "autoprefixer": "^10.4.12",
74 | "husky": "^8.0.3",
75 | "lint-staged": "^13.2.0",
76 | "postcss": "^8.4.18",
77 | "prettier": "2.8.4",
78 | "prettier-plugin-tailwindcss": "^0.2.2",
79 | "semantic-release": "^20.1.1",
80 | "tailwindcss": "^3.2.4",
81 | "typescript": "4.9.4"
82 | },
83 | "version": "2.38.0",
84 | "browser": {
85 | "fs": false,
86 | "path": false,
87 | "os": false
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/pages/user/videos.tsx:
--------------------------------------------------------------------------------
1 | import { Sidebar } from '~/components/sidebar'
2 | import BilibiliEmbedRenderer from 'react-bilibili-embed-renderer'
3 | export default () => {
4 | return (
5 | <>
6 |
7 |
8 |
9 |
24 |
25 |
💺 虚位以待,Coming Soon!
26 |
27 |
35 |
38 |
52 |
53 |
54 | >
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/pages/api/sumup.ts:
--------------------------------------------------------------------------------
1 | import type { NextFetchEvent, NextRequest } from 'next/server'
2 | import { NextResponse } from 'next/server'
3 | import { fetchSubtitle } from '~/lib/fetchSubtitle'
4 | import { ChatGPTAgent, fetchOpenAIResult } from '~/lib/openai/fetchOpenAIResult'
5 | import { getSmallSizeTranscripts } from '~/lib/openai/getSmallSizeTranscripts'
6 | import { getUserSubtitlePrompt, getUserSubtitleWithTimestampPrompt } from '~/lib/openai/prompt'
7 | import { selectApiKeyAndActivatedLicenseKey } from '~/lib/openai/selectApiKeyAndActivatedLicenseKey'
8 | import { SummarizeParams } from '~/lib/types'
9 | import { isDev } from '~/utils/env'
10 |
11 | export const config = {
12 | runtime: 'edge',
13 | }
14 |
15 | if (!process.env.OPENAI_API_KEY) {
16 | throw new Error('Missing env var from OpenAI')
17 | }
18 |
19 | export default async function handler(req: NextRequest, context: NextFetchEvent) {
20 | const { videoConfig, userConfig } = (await req.json()) as SummarizeParams
21 | const { userKey, shouldShowTimestamp } = userConfig
22 | const { videoId } = videoConfig
23 |
24 | if (!videoId) {
25 | return new Response('No videoId in the request', { status: 500 })
26 | }
27 | const { title, subtitlesArray, descriptionText } = await fetchSubtitle(videoConfig, shouldShowTimestamp)
28 | if (!subtitlesArray && !descriptionText) {
29 | console.error('No subtitle in the video: ', videoId)
30 | return new Response('No subtitle in the video', { status: 501 })
31 | }
32 | const inputText = subtitlesArray ? getSmallSizeTranscripts(subtitlesArray, subtitlesArray) : descriptionText // subtitlesArray.map((i) => i.text).join("\n")
33 |
34 | // TODO: try the apiKey way for chrome extensions
35 | // const systemPrompt = getSystemPrompt({
36 | // shouldShowTimestamp: subtitlesArray ? shouldShowTimestamp : false,
37 | // });
38 | // const examplePrompt = getExamplePrompt();
39 | const userPrompt = shouldShowTimestamp
40 | ? getUserSubtitleWithTimestampPrompt(title, inputText, videoConfig)
41 | : getUserSubtitlePrompt(title, inputText, videoConfig)
42 | if (isDev) {
43 | // console.log("final system prompt: ", systemPrompt);
44 | // console.log("final example prompt: ", examplePrompt);
45 | console.log('final user prompt: ', userPrompt)
46 | }
47 |
48 | try {
49 | const stream = true
50 | const openAiPayload = {
51 | model: 'gpt-3.5-turbo',
52 | messages: [
53 | // { role: ChatGPTAgent.system, content: systemPrompt },
54 | // { role: ChatGPTAgent.user, content: examplePrompt.input },
55 | // { role: ChatGPTAgent.assistant, content: examplePrompt.output },
56 | { role: ChatGPTAgent.user, content: userPrompt },
57 | ],
58 | // temperature: 0.5,
59 | // top_p: 1,
60 | // frequency_penalty: 0,
61 | // presence_penalty: 0,
62 | max_tokens: Number(videoConfig.detailLevel) || (userKey ? 800 : 600),
63 | stream,
64 | // n: 1,
65 | }
66 |
67 | // TODO: need refactor
68 | const openaiApiKey = await selectApiKeyAndActivatedLicenseKey(userKey, videoId)
69 | const result = await fetchOpenAIResult(openAiPayload, openaiApiKey, videoConfig)
70 | if (stream) {
71 | return new Response(result)
72 | }
73 |
74 | return NextResponse.json(result)
75 | } catch (error: any) {
76 | console.error(error.message)
77 | return new Response(
78 | JSON.stringify({
79 | errorMessage: error.message,
80 | }),
81 | {
82 | status: 500,
83 | },
84 | )
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/components/user-dropdown.tsx:
--------------------------------------------------------------------------------
1 | import { useSupabaseClient, useUser } from '@supabase/auth-helpers-react'
2 | import { motion } from 'framer-motion'
3 | import { Clover, Edit, LayoutDashboard, LogOut, ShoppingBag } from 'lucide-react'
4 | import Link from 'next/link'
5 | import { useState } from 'react'
6 | import Popover from '~/components/shared/popover'
7 | import { FADE_IN_ANIMATION_SETTINGS } from '~/utils/constants'
8 |
9 | export default function UserDropdown() {
10 | // const { data: session } = useSession();
11 | const user = useUser()
12 | const supabaseClient = useSupabaseClient()
13 | const { email, user_metadata } = user || {}
14 | const image = user_metadata?.avatar_url
15 | const [openPopover, setOpenPopover] = useState(false)
16 |
17 | if (!email) return null
18 |
19 | async function signOut(param: { redirect: boolean }) {
20 | const { error } = await supabaseClient.auth.signOut()
21 | error && console.error('=======sign out error=====', { error })
22 | }
23 |
24 | return (
25 |
26 |
29 |
33 |
34 | 个人中心
35 |
36 |
40 |
41 | 导出笔记
42 |
43 |
47 |
48 | 购买次数
49 |
50 |
54 |
55 | 奖励计划
56 |
57 |
64 |
65 | }
66 | align="end"
67 | openPopover={openPopover}
68 | setOpenPopover={setOpenPopover}
69 | >
70 |
81 |
82 |
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > This repo is only for v1 and supports Bilibil and YouTube!
2 |
3 | # 🤖 BibiGPT: one-Click AI Summary for Audio/Video & Chat with Learning Content [https://bibigpt.co](https://bibigpt.co)
4 |
5 | 🎉 Effortlessly summarize YouTube and Bilibili videos with our AI-driven Video Summarizer. It also works for Podcasts, Twitter, Meetings, Lectures, Tiktok videos, and more. Discover a more brilliant way to learn with ChatGPT, your best AI-powered study companion! (formerly BiliGPT) "stream-saving artifact & class representative".
6 |
7 | Alternate address: https://b.jimmylv.cn
8 | Browser extension: https://bibigpt.co/extension
9 |
10 | ---
11 |
12 | ## 🤖 BibiGPT · AI 音视频内容一键总结 & 对话 [https://bibigpt.co](https://bibigpt.co)
13 |
14 | 🎉 ChatGPT AI 音视频一键总结,轻松学习哔哩哔哩丨 YouTube 丨本地视频丨本地音频丨播客丨小红书丨抖音丨会议丨讲座丨网页等任意内容。BibiGPT 助力于成为最好的 AI 学习助理,支持免费试用!(原 BiliGPT 省流神器 & AI 课代表)(支持 iOS 快捷指令 & 微信服务号)。
15 |
16 | 备用地址:https://b.jimmylv.cn
17 | 浏览器插件: https://bibigpt.co/extension
18 |
19 | ---
20 |
21 | 🎬 This project summarizes YouTube/Bilibili/Twitter/TikTok/Podcast/Lecture/Meeting/... videos or audios for you using AI.
22 |
23 | 🤯 Inspired by [Nutlope/news-summarizer](https://github.com/Nutlope/news-summarizer) & [zhengbangbo/chat-simplifier](https://github.com/zhengbangbo/chat-simplifier/) & [lxfater/BilibiliSummary](https://github.com/lxfater/BilibiliSummary)
24 |
25 | [](https://twitter.com/Jimmy_JingLv/status/1630137750572728320?s=20)
26 |
27 | 🚀 First Launch: [【BibiGPT】AI 自动总结 B 站视频内容,GPT-3 智能提取并总结字幕](https://www.bilibili.com/video/BV1fX4y1Q7Ux/?vd_source=dd5a650b0ad84edd0d54bb18196ecb86)
28 |
29 | ## How it works
30 |
31 | This project uses the [OpenAI ChatGPT API](https://openai.com/api/) (specifically, gpt-3.5-turbo) and [Vercel Edge functions](https://vercel.com/features/edge-functions) with streaming and [Upstash](https://console.upstash.com/) for Redis cache and rate limiting. It fetches the content on a Bilibili video, sends it in a prompt to the GPT-3 API to summarize it via a Vercel Edge function, then streams the response back to the application.
32 |
33 | ## Saving costs
34 |
35 | Projects like this can get expensive so in order to save costs if you want to make your own version and share it publicly, I recommend three things:
36 |
37 | - [x] 1. Implement rate limiting so people can't abuse your site
38 | - [x] 2. Implement caching to avoid expensive AI re-generations
39 | - [x] 3. Use `text-curie-001` instead of `text-dacinci-003` in the `summarize` edge function
40 |
41 | ## Running Locally
42 |
43 | After cloning the repo, go to [OpenAI](https://beta.openai.com/account/api-keys) to make an account and put your API key in a file called `.env`.
44 |
45 | Then, run the application in the command line and it will be available at `http://localhost:3000`.
46 |
47 | [specific running procedure is described in this document - Chinese version](./deploy-ch.md)
48 |
49 | ```bash
50 | npm run dev
51 | ```
52 |
53 | ## Deployment
54 |
55 | Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples)
56 |
57 | Setup the env variables, by following the `./example.env` file.
58 |
59 | ## Support Docker
60 |
61 | https://github.com/JimmyLv/BibiGPT/pull/133
62 |
63 | ```shell
64 | # make sure setup .env file firstly
65 | docker compose up -d
66 | ```
67 |
68 | ## Support -> Contact Me
69 |
70 | 
71 |
72 | ## Star History
73 |
74 | [](https://star-history.com/#JimmyLv/BibiGPT&Date)
75 |
76 | ## Contributors
77 |
78 | This project exists thanks to all the people who contribute.
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/hooks/useSummarize.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { useToast } from '~/hooks/use-toast'
3 | import { UserConfig, VideoConfig } from '~/lib/types'
4 | import { RATE_LIMIT_COUNT } from '~/utils/constants'
5 |
6 | export function useSummarize(showSingIn: (show: boolean) => void, enableStream: boolean = true) {
7 | const [loading, setLoading] = useState(false)
8 | const [summary, setSummary] = useState('')
9 | const { toast } = useToast()
10 |
11 | const resetSummary = () => {
12 | setSummary('')
13 | }
14 |
15 | const summarize = async (videoConfig: VideoConfig, userConfig: UserConfig) => {
16 | setSummary('')
17 | setLoading(true)
18 |
19 | try {
20 | setLoading(true)
21 | const response = await fetch('/api/sumup', {
22 | method: 'POST',
23 | headers: {
24 | 'Content-Type': 'application/json',
25 | },
26 | body: JSON.stringify({
27 | videoConfig,
28 | userConfig,
29 | }),
30 | })
31 |
32 | if (response.redirected) {
33 | window.location.href = response.url
34 | }
35 |
36 | if (!response.ok) {
37 | console.log('error', response)
38 | if (response.status === 501) {
39 | toast({
40 | title: '啊叻?视频字幕不见了?!',
41 | description: `\n(这个视频太短了...\n或者还没有字幕哦!)`,
42 | })
43 | } else if (response.status === 504) {
44 | toast({
45 | variant: 'destructive',
46 | title: `网站访问量过大`,
47 | description: `每日限额使用 ${RATE_LIMIT_COUNT} 次哦!`,
48 | })
49 | } else if (response.status === 401) {
50 | toast({
51 | variant: 'destructive',
52 | title: `${response.statusText} 请登录哦!`,
53 | // ReadableStream can't get error message
54 | // description: response.body
55 | description: '每天的免费次数已经用完啦,🆓',
56 | })
57 | showSingIn(true)
58 | } else {
59 | const errorJson = await response.json()
60 | toast({
61 | variant: 'destructive',
62 | title: response.status + ' ' + response.statusText,
63 | // ReadableStream can't get error message
64 | description: errorJson.errorMessage,
65 | })
66 | }
67 | setLoading(false)
68 | return
69 | }
70 |
71 | if (enableStream) {
72 | // This data is a ReadableStream
73 | const data = response.body
74 | if (!data) {
75 | return
76 | }
77 |
78 | const reader = data.getReader()
79 | const decoder = new TextDecoder()
80 | let done = false
81 |
82 | while (!done) {
83 | const { value, done: doneReading } = await reader.read()
84 | done = doneReading
85 | const chunkValue = decoder.decode(value)
86 | setSummary((prev) => prev + chunkValue)
87 | }
88 | setLoading(false)
89 | return
90 | }
91 | // await readStream(response, setSummary);
92 | const result = await response.json()
93 | if (result.errorMessage) {
94 | setLoading(false)
95 | toast({
96 | variant: 'destructive',
97 | title: 'API 请求出错,请重试。',
98 | description: result.errorMessage,
99 | })
100 | return
101 | }
102 | setSummary(result)
103 | setLoading(false)
104 | } catch (e: any) {
105 | console.error('[fetch ERROR]', e)
106 | toast({
107 | variant: 'destructive',
108 | title: '未知错误:',
109 | description: e.message || e.errorMessage,
110 | })
111 | setLoading(false)
112 | }
113 | }
114 | return { loading, summary, resetSummary, summarize }
115 | }
116 |
--------------------------------------------------------------------------------
/lib/openai/getSmallSizeTranscripts.ts:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2022 Kazuki Nakayashiki.
2 | // Modified work: Copyright (c) 2023 Qixiang Zhu.
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy
4 | // of this software and associated documentation files (the "Software"), to deal
5 | // in the Software without restriction, including without limitation the rights
6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | // copies of the Software, and to permit persons to whom the Software is
8 | // furnished to do so, subject to the following conditions:
9 |
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 |
13 | // via https://github.com/lxfater/BilibiliSummary/blob/3d1a67cbe8e96adba60672b778ce89644a43280d/src/prompt.ts#L62
14 | export function limitTranscriptByteLength(str: string, byteLimit: number = LIMIT_COUNT) {
15 | const utf8str = unescape(encodeURIComponent(str))
16 | const byteLength = utf8str.length
17 | if (byteLength > byteLimit) {
18 | const ratio = byteLimit / byteLength
19 | const newStr = str.substring(0, Math.floor(str.length * ratio))
20 | return newStr
21 | }
22 | return str
23 | }
24 | function filterHalfRandomly(arr: T[]): T[] {
25 | const filteredArr: T[] = []
26 | const halfLength = Math.floor(arr.length / 2)
27 | const indicesToFilter = new Set()
28 |
29 | // 随机生成要过滤掉的元素的下标
30 | while (indicesToFilter.size < halfLength) {
31 | const index = Math.floor(Math.random() * arr.length)
32 | if (!indicesToFilter.has(index)) {
33 | indicesToFilter.add(index)
34 | }
35 | }
36 |
37 | // 过滤掉要过滤的元素
38 | for (let i = 0; i < arr.length; i++) {
39 | if (!indicesToFilter.has(i)) {
40 | filteredArr.push(arr[i])
41 | }
42 | }
43 |
44 | return filteredArr
45 | }
46 | function getByteLength(text: string) {
47 | return unescape(encodeURIComponent(text)).length
48 | }
49 |
50 | function itemInIt(textData: SubtitleItem[], text: string): boolean {
51 | return textData.find((t) => t.text === text) !== undefined
52 | }
53 |
54 | type SubtitleItem = {
55 | text: string
56 | index: number
57 | }
58 |
59 | // Seems like 15,000 bytes is the limit for the prompt
60 | // 13000 = 6500*2
61 | const LIMIT_COUNT = 6200 // 2000 is a buffer
62 | export function getSmallSizeTranscripts(
63 | newTextData: SubtitleItem[],
64 | oldTextData: SubtitleItem[],
65 | byteLimit: number = LIMIT_COUNT,
66 | ): string {
67 | const text = newTextData
68 | .sort((a, b) => a.index - b.index)
69 | .map((t) => t.text)
70 | .join(' ')
71 | const byteLength = getByteLength(text)
72 |
73 | if (byteLength > byteLimit) {
74 | const filtedData = filterHalfRandomly(newTextData)
75 | return getSmallSizeTranscripts(filtedData, oldTextData, byteLimit)
76 | }
77 |
78 | let resultData = newTextData.slice()
79 | let resultText = text
80 | let lastByteLength = byteLength
81 |
82 | for (let i = 0; i < oldTextData.length; i++) {
83 | const obj = oldTextData[i]
84 | if (itemInIt(newTextData, obj.text)) {
85 | continue
86 | }
87 |
88 | const nextTextByteLength = getByteLength(obj.text)
89 | const isOverLimit = lastByteLength + nextTextByteLength > byteLimit
90 | if (isOverLimit) {
91 | const overRate = (lastByteLength + nextTextByteLength - byteLimit) / nextTextByteLength
92 | const chunkedText = obj.text.substring(0, Math.floor(obj.text.length * overRate))
93 | resultData.push({ text: chunkedText, index: obj.index })
94 | } else {
95 | resultData.push(obj)
96 | }
97 | resultText = resultData
98 | .sort((a, b) => a.index - b.index)
99 | .map((t) => t.text)
100 | .join(' ')
101 | lastByteLength = getByteLength(resultText)
102 | }
103 |
104 | return resultText
105 | }
106 |
--------------------------------------------------------------------------------
/lib/openai/fetchOpenAIResult.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from '@upstash/redis'
2 | import { createParser, ParsedEvent, ReconnectInterval } from 'eventsource-parser'
3 | import { trimOpenAiResult } from '~/lib/openai/trimOpenAiResult'
4 | import { VideoConfig } from '~/lib/types'
5 | import { isDev } from '~/utils/env'
6 | import { getCacheId } from '~/utils/getCacheId'
7 |
8 | export enum ChatGPTAgent {
9 | user = 'user',
10 | system = 'system',
11 | assistant = 'assistant',
12 | }
13 |
14 | export interface ChatGPTMessage {
15 | role: ChatGPTAgent
16 | content: string
17 | }
18 | export interface OpenAIStreamPayload {
19 | api_key?: string
20 | model: string
21 | messages: ChatGPTMessage[]
22 | temperature?: number
23 | top_p?: number
24 | frequency_penalty?: number
25 | presence_penalty?: number
26 | max_tokens: number
27 | stream: boolean
28 | n?: number
29 | }
30 |
31 | export async function fetchOpenAIResult(payload: OpenAIStreamPayload, apiKey: string, videoConfig: VideoConfig) {
32 | const encoder = new TextEncoder()
33 | const decoder = new TextDecoder()
34 |
35 | isDev && console.log({ apiKey })
36 | const res = await fetch('https://api.openai.com/v1/chat/completions', {
37 | headers: {
38 | 'Content-Type': 'application/json',
39 | Authorization: `Bearer ${apiKey ?? ''}`,
40 | },
41 | method: 'POST',
42 | body: JSON.stringify(payload),
43 | })
44 |
45 | if (res.status !== 200) {
46 | const errorJson = await res.json()
47 | throw new Error(`OpenAI API Error [${res.statusText}]: ${errorJson.error?.message}`)
48 | }
49 |
50 | const redis = Redis.fromEnv()
51 | const cacheId = getCacheId(videoConfig)
52 |
53 | if (!payload.stream) {
54 | const result = await res.json()
55 | const betterResult = trimOpenAiResult(result)
56 |
57 | const data = await redis.set(cacheId, betterResult)
58 | console.info(`video ${cacheId} cached:`, data)
59 | isDev && console.log('========betterResult========', betterResult)
60 |
61 | return betterResult
62 | }
63 |
64 | let counter = 0
65 | let tempData = ''
66 | const stream = new ReadableStream({
67 | async start(controller) {
68 | // callback
69 | async function onParse(event: ParsedEvent | ReconnectInterval) {
70 | if (event.type === 'event') {
71 | const data = event.data
72 | // https://beta.openai.com/docs/api-reference/completions/create#completions/create-stream
73 | if (data === '[DONE]') {
74 | // active
75 | controller.close()
76 | const data = await redis.set(cacheId, tempData)
77 | console.info(`video ${cacheId} cached:`, data)
78 | isDev && console.log('========betterResult after streamed========', tempData)
79 | return
80 | }
81 | try {
82 | const json = JSON.parse(data)
83 | const text = json.choices[0].delta?.content || ''
84 | // todo: add redis cache
85 | tempData += text
86 | if (counter < 2 && (text.match(/\n/) || []).length) {
87 | // this is a prefix character (i.e., "\n\n"), do nothing
88 | return
89 | }
90 | const queue = encoder.encode(text)
91 | controller.enqueue(queue)
92 | counter++
93 | } catch (e) {
94 | // maybe parse error
95 | controller.error(e)
96 | }
97 | }
98 | }
99 |
100 | // stream response (SSE) from OpenAI may be fragmented into multiple chunks
101 | // this ensures we properly read chunks and invoke an event for each SSE event stream
102 | const parser = createParser(onParse)
103 | // https://web.dev/streams/#asynchronous-iteration
104 | for await (const chunk of res.body as any) {
105 | parser.feed(decoder.decode(chunk))
106 | }
107 | },
108 | })
109 |
110 | return stream
111 | }
112 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as DialogPrimitive from '@radix-ui/react-dialog'
5 | import { X } from 'lucide-react'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = ({ className, children, ...props }: DialogPrimitive.DialogPortalProps) => (
14 |
15 | {children}
16 |
17 | )
18 | DialogPortal.displayName = DialogPrimitive.Portal.displayName
19 |
20 | const DialogOverlay = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, children, ...props }, ref) => (
24 |
32 | ))
33 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
34 |
35 | const DialogContent = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, children, ...props }, ref) => (
39 |
40 |
41 |
50 | {children}
51 |
52 |
53 | Close
54 |
55 |
56 |
57 | ))
58 | DialogContent.displayName = DialogPrimitive.Content.displayName
59 |
60 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
61 |
62 | )
63 | DialogHeader.displayName = 'DialogHeader'
64 |
65 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
66 |
67 | )
68 | DialogFooter.displayName = 'DialogFooter'
69 |
70 | const DialogTitle = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, ...props }, ref) => (
74 |
79 | ))
80 | DialogTitle.displayName = DialogPrimitive.Title.displayName
81 |
82 | const DialogDescription = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
91 | ))
92 | DialogDescription.displayName = DialogPrimitive.Description.displayName
93 |
94 | export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }
95 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SelectPrimitive from '@radix-ui/react-select'
5 | import { Check, ChevronDown } from 'lucide-react'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 |
27 | {children}
28 |
29 |
30 | ))
31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
32 |
33 | const SelectContent = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, children, ...props }, ref) => (
37 |
38 |
46 | {children}
47 |
48 |
49 | ))
50 | SelectContent.displayName = SelectPrimitive.Content.displayName
51 |
52 | const SelectLabel = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
61 | ))
62 | SelectLabel.displayName = SelectPrimitive.Label.displayName
63 |
64 | const SelectItem = React.forwardRef<
65 | React.ElementRef,
66 | React.ComponentPropsWithoutRef
67 | >(({ className, children, ...props }, ref) => (
68 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | {children}
83 |
84 | ))
85 | SelectItem.displayName = SelectPrimitive.Item.displayName
86 |
87 | const SelectSeparator = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => (
91 |
96 | ))
97 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
98 |
99 | export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator }
100 |
--------------------------------------------------------------------------------
/components/sign-in-modal.tsx:
--------------------------------------------------------------------------------
1 | import { useSupabaseClient } from '@supabase/auth-helpers-react'
2 | import { Auth } from '@supabase/auth-ui-react'
3 | import { ThemeSupa } from '@supabase/auth-ui-shared'
4 | import Image from 'next/image'
5 | import Link from 'next/link'
6 | import React, { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'
7 | import { useAnalytics } from '~/components/context/analytics'
8 | import Modal from '~/components/shared/modal'
9 | import { BASE_DOMAIN, CHECKOUT_URL, LOGIN_LIMIT_COUNT } from '~/utils/constants'
10 | import { getRedirectURL } from '~/utils/getRedirectUrl'
11 |
12 | const SignInModal = ({
13 | showSignInModal,
14 | setShowSignInModal,
15 | }: {
16 | showSignInModal: boolean
17 | setShowSignInModal: Dispatch>
18 | }) => {
19 | const supabaseClient = useSupabaseClient()
20 | const redirectURL = getRedirectURL()
21 | const { analytics } = useAnalytics()
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
登录 BibiGPT
31 |
42 |
Input, Prompt, Output
43 |
44 |
45 |
81 |
82 | 点击登录或注册,即同意
83 |
84 | 服务条款
85 |
86 | 和
87 |
88 | 隐私政策
89 |
90 | 。
91 |
92 |
93 |
94 | )
95 | }
96 |
97 | export function useSignInModal() {
98 | const [showSignInModal, setShowSignInModal] = useState(false)
99 |
100 | const SignInModalCallback = useCallback(() => {
101 | return
102 | }, [showSignInModal, setShowSignInModal])
103 |
104 | return useMemo(
105 | () => ({ setShowSignInModal, SignInModal: SignInModalCallback }),
106 | [setShowSignInModal, SignInModalCallback],
107 | )
108 | }
109 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs'
2 | import { Redis } from '@upstash/redis'
3 | import type { NextFetchEvent, NextRequest } from 'next/server'
4 | import { NextResponse } from 'next/server'
5 | import { SummarizeParams } from '~/lib/types'
6 | import { getCacheId } from '~/utils/getCacheId'
7 | import { validateLicenseKey } from './lib/lemon'
8 | import { checkOpenaiApiKeys } from './lib/openai/checkOpenaiApiKey'
9 | import { ratelimitForApiKeyIps, ratelimitForFreeAccounts, ratelimitForIps } from './lib/upstash'
10 | import { isDev } from './utils/env'
11 |
12 | const redis = Redis.fromEnv()
13 |
14 | /**
15 | *
16 | * Respond with JSON indicating an error message
17 | * @return {*} {NextResponse}
18 | */
19 | function redirectAuth(): NextResponse {
20 | // return NextResponse.redirect(new URL("/shop", req.url));
21 | console.error('Authentication Failed')
22 | return new NextResponse(JSON.stringify({ success: false, message: 'Authentication Failed' }), {
23 | status: 401,
24 | headers: { 'content-type': 'application/json' },
25 | })
26 | }
27 |
28 | /**
29 | * Redirects the user to the page where the number of uses is purchased
30 | *
31 | * @param {NextRequest} req
32 | * @return {*} {NextResponse}
33 | */
34 | function redirectShop(req: NextRequest): NextResponse {
35 | console.error('Account Limited')
36 | return NextResponse.redirect(new URL('/shop', req.url))
37 | }
38 |
39 | export async function middleware(req: NextRequest, context: NextFetchEvent) {
40 | try {
41 | const { userConfig, videoConfig } = (await req.json()) as SummarizeParams
42 | // TODO: update shouldShowTimestamp to use videoConfig
43 | const { userKey } = userConfig || {}
44 | const cacheId = getCacheId(videoConfig)
45 | const ipIdentifier = req.ip ?? '127.0.0.11'
46 |
47 | // licenseKeys
48 | if (userKey) {
49 | if (checkOpenaiApiKeys(userKey)) {
50 | const { success, remaining } = await ratelimitForApiKeyIps.limit(ipIdentifier)
51 | console.log(`use user apiKey ${ipIdentifier}, remaining: ${remaining}`)
52 | if (!success) {
53 | return redirectShop(req)
54 | }
55 |
56 | return NextResponse.next()
57 | }
58 |
59 | // 3. something-invalid-sdalkjfasncs-key
60 | const isValidatedLicense = await validateLicenseKey(userKey, cacheId)
61 | if (!isValidatedLicense) {
62 | return redirectShop(req)
63 | }
64 | }
65 |
66 | if (isDev) {
67 | return NextResponse.next()
68 | }
69 | // 👇 below only works for production
70 |
71 | if (!userKey) {
72 | const { success, remaining } = await ratelimitForIps.limit(ipIdentifier)
73 | console.log(`ip free user ${ipIdentifier}, remaining: ${remaining}`)
74 | if (!success) {
75 | // We need to create a response and hand it to the supabase client to be able to modify the response headers.
76 | const res = NextResponse.next()
77 | // TODO: unique to a user (userid, email etc) instead of IP
78 | // Create authenticated Supabase Client.
79 | const supabase = createMiddlewareSupabaseClient({ req, res })
80 | // Check if we have a session
81 | const {
82 | data: { session },
83 | } = await supabase.auth.getSession()
84 | // Check auth condition
85 | const userEmail = session?.user.email
86 | if (userEmail) {
87 | // Authentication successful, forward request to protected route.
88 | const { success, remaining } = await ratelimitForFreeAccounts.limit(userEmail)
89 | // TODO: only reduce the count after summarized successfully
90 | console.log(`login user ${userEmail}, remaining: ${remaining}`)
91 | if (!success) {
92 | return redirectShop(req)
93 | }
94 |
95 | return res
96 | }
97 |
98 | // todo: throw error to trigger a modal, rather than redirect a page
99 | return redirectAuth()
100 | }
101 | }
102 |
103 | const result = await redis.get(cacheId)
104 | if (result) {
105 | console.log('hit cache for ', cacheId)
106 | return NextResponse.json(result)
107 | }
108 | } catch (e) {
109 | console.error(e)
110 | return redirectAuth()
111 | }
112 | }
113 |
114 | export const config = {
115 | matcher: '/api/sumup',
116 | }
117 |
--------------------------------------------------------------------------------
/components/PromptOptions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { UseFormReturn } from 'react-hook-form/dist/types/form'
3 | import { PROMPT_LANGUAGE_MAP } from '~/utils/constants/language'
4 |
5 | export function PromptOptions({
6 | register,
7 | getValues,
8 | }: {
9 | // TODO: add types
10 | register: any
11 | getValues: UseFormReturn['getValues']
12 | }) {
13 | const shouldShowTimestamp = getValues('showTimestamp')
14 | return (
15 |
96 | )
97 | }
98 |
--------------------------------------------------------------------------------
/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from 'react'
3 |
4 | import { ToastActionElement, type ToastProps } from '@/components/ui/toast'
5 |
6 | const TOAST_LIMIT = 1
7 | const TOAST_REMOVE_DELAY = 1000
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string
11 | title?: React.ReactNode
12 | description?: React.ReactNode
13 | action?: ToastActionElement
14 | }
15 |
16 | const actionTypes = {
17 | ADD_TOAST: 'ADD_TOAST',
18 | UPDATE_TOAST: 'UPDATE_TOAST',
19 | DISMISS_TOAST: 'DISMISS_TOAST',
20 | REMOVE_TOAST: 'REMOVE_TOAST',
21 | } as const
22 |
23 | let count = 0
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_VALUE
27 | return count.toString()
28 | }
29 |
30 | type ActionType = typeof actionTypes
31 |
32 | type Action =
33 | | {
34 | type: ActionType['ADD_TOAST']
35 | toast: ToasterToast
36 | }
37 | | {
38 | type: ActionType['UPDATE_TOAST']
39 | toast: Partial
40 | }
41 | | {
42 | type: ActionType['DISMISS_TOAST']
43 | toastId?: ToasterToast['id']
44 | }
45 | | {
46 | type: ActionType['REMOVE_TOAST']
47 | toastId?: ToasterToast['id']
48 | }
49 |
50 | interface State {
51 | toasts: ToasterToast[]
52 | }
53 |
54 | const toastTimeouts = new Map>()
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId)
63 | dispatch({
64 | type: 'REMOVE_TOAST',
65 | toastId: toastId,
66 | })
67 | }, TOAST_REMOVE_DELAY)
68 |
69 | toastTimeouts.set(toastId, timeout)
70 | }
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case 'ADD_TOAST':
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | }
79 |
80 | case 'UPDATE_TOAST':
81 | return {
82 | ...state,
83 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
84 | }
85 |
86 | case 'DISMISS_TOAST': {
87 | const { toastId } = action
88 |
89 | // ! Side effects ! - This could be extracted into a dismissToast() action,
90 | // but I'll keep it here for simplicity
91 | if (toastId) {
92 | addToRemoveQueue(toastId)
93 | } else {
94 | state.toasts.forEach((toast) => {
95 | addToRemoveQueue(toast.id)
96 | })
97 | }
98 |
99 | return {
100 | ...state,
101 | toasts: state.toasts.map((t) =>
102 | t.id === toastId || toastId === undefined
103 | ? {
104 | ...t,
105 | open: false,
106 | }
107 | : t,
108 | ),
109 | }
110 | }
111 | case 'REMOVE_TOAST':
112 | if (action.toastId === undefined) {
113 | return {
114 | ...state,
115 | toasts: [],
116 | }
117 | }
118 | return {
119 | ...state,
120 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
121 | }
122 | }
123 | }
124 |
125 | const listeners: Array<(state: State) => void> = []
126 |
127 | let memoryState: State = { toasts: [] }
128 |
129 | function dispatch(action: Action) {
130 | memoryState = reducer(memoryState, action)
131 | listeners.forEach((listener) => {
132 | listener(memoryState)
133 | })
134 | }
135 |
136 | interface Toast extends Omit {}
137 |
138 | function toast({ ...props }: Toast) {
139 | // console.log('========toast props========', props)
140 | const id = genId()
141 |
142 | const update = (props: ToasterToast) =>
143 | dispatch({
144 | type: 'UPDATE_TOAST',
145 | toast: { ...props, id },
146 | })
147 | const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
148 |
149 | dispatch({
150 | type: 'ADD_TOAST',
151 | toast: {
152 | ...props,
153 | id,
154 | open: true,
155 | onOpenChange: (open) => {
156 | if (!open) dismiss()
157 | },
158 | },
159 | })
160 |
161 | return {
162 | id: id,
163 | dismiss,
164 | update,
165 | }
166 | }
167 |
168 | function useToast() {
169 | const [state, setState] = React.useState(memoryState)
170 |
171 | React.useEffect(() => {
172 | listeners.push(setState)
173 | return () => {
174 | const index = listeners.indexOf(setState)
175 | if (index > -1) {
176 | listeners.splice(index, 1)
177 | }
178 | }
179 | }, [state])
180 |
181 | return {
182 | ...state,
183 | toast,
184 | dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
185 | }
186 | }
187 |
188 | export { useToast, toast }
189 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import * as ToastPrimitives from '@radix-ui/react-toast'
3 | import { VariantProps, cva } from 'class-variance-authority'
4 | import { X } from 'lucide-react'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | 'data-[swipe=move]:transition-none grow-1 group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full mt-4 data-[state=closed]:slide-out-to-right-full dark:border-slate-700 last:mt-0 sm:last:mt-4',
27 | {
28 | variants: {
29 | variant: {
30 | default: 'bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700',
31 | destructive: 'group destructive bg-red-500 text-white border-red-500 dark:border-red-500',
32 | },
33 | },
34 | defaultVariants: {
35 | variant: 'default',
36 | },
37 | },
38 | )
39 |
40 | const Toast = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef & VariantProps
43 | >(({ className, variant, ...props }, ref) => {
44 | return
45 | })
46 | Toast.displayName = ToastPrimitives.Root.displayName
47 |
48 | const ToastAction = React.forwardRef<
49 | React.ElementRef,
50 | React.ComponentPropsWithoutRef
51 | >(({ className, ...props }, ref) => (
52 |
60 | ))
61 | ToastAction.displayName = ToastPrimitives.Action.displayName
62 |
63 | const ToastClose = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, ...props }, ref) => (
67 |
76 |
77 |
78 | ))
79 | ToastClose.displayName = ToastPrimitives.Close.displayName
80 |
81 | const ToastTitle = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
86 | ))
87 | ToastTitle.displayName = ToastPrimitives.Title.displayName
88 |
89 | const ToastDescription = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
94 | ))
95 | ToastDescription.displayName = ToastPrimitives.Description.displayName
96 |
97 | type ToastProps = React.ComponentPropsWithoutRef
98 |
99 | type ToastActionElement = React.ReactElement
100 |
101 | export {
102 | type ToastProps,
103 | type ToastActionElement,
104 | ToastProvider,
105 | ToastViewport,
106 | Toast,
107 | ToastTitle,
108 | ToastDescription,
109 | ToastClose,
110 | ToastAction,
111 | }
112 |
--------------------------------------------------------------------------------
/pages/user/integration.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Sidebar } from '~/components/sidebar'
3 | import { useLocalStorage } from '~/hooks/useLocalStorage'
4 |
5 | export default () => {
6 | const [flomoWebhook, setFlomoWebhook] = useLocalStorage('user-flomo-webhook')
7 | const handleFlomoWebhook = (e: any) => {
8 | setFlomoWebhook(e.target.value)
9 | }
10 |
11 | const [larkWebhook, setLarkWebhook] = useLocalStorage('user-lark-webhook')
12 | const handleLarkWebhook = (e: any) => {
13 | setLarkWebhook(e.target.value)
14 | }
15 |
16 | return (
17 | <>
18 |
19 |
20 |
21 |
22 |
Flomo 浮墨笔记
23 |
46 |
47 |
48 |
飞书 Webhook
49 |
72 |
73 |
74 |
75 |
Roam (coming soon)
76 |
77 |
78 |
Notion (coming soon)
79 |
80 |
81 |
84 |
98 |
99 |
100 | >
101 | )
102 | }
103 |
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Poppins } from '@next/font/google'
2 | import clsx from 'clsx'
3 | import Image from 'next/image'
4 | import React from 'react'
5 | import SignIn from '~/components/SignIn'
6 | import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'
7 | import { BASE_DOMAIN } from '~/utils/constants'
8 | import Github from '../components/GitHub'
9 | const poppins = Poppins({ weight: '800', subsets: ['latin'] })
10 |
11 | export default function Header({ showSingIn }: { showSingIn: (show: boolean) => void }) {
12 | return (
13 |
113 | )
114 | }
115 |
--------------------------------------------------------------------------------
/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import { DialogProps } from '@radix-ui/react-dialog'
5 | import { Command as CommandPrimitive, useCommandState } from 'cmdk'
6 | import { ChevronsUpDown, Search } from 'lucide-react'
7 |
8 | import { cn } from '@/lib/utils'
9 | import { Dialog, DialogContent } from '@/components/ui/dialog'
10 |
11 | const Command = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | Command.displayName = CommandPrimitive.displayName
22 |
23 | interface CommandDialogProps extends DialogProps {}
24 |
25 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
26 | return (
27 |
34 | )
35 | }
36 |
37 | const CommandInput = React.forwardRef<
38 | React.ElementRef,
39 | React.ComponentPropsWithoutRef
40 | >(({ className, ...props }, ref) => (
41 |
42 |
43 |
51 |
52 | ))
53 |
54 | CommandInput.displayName = CommandPrimitive.Input.displayName
55 |
56 | const CommandList = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
65 | ))
66 |
67 | CommandList.displayName = CommandPrimitive.List.displayName
68 |
69 | const CommandEmpty = React.forwardRef<
70 | React.ElementRef,
71 | React.ComponentPropsWithoutRef
72 | >((props, ref) => )
73 |
74 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
75 |
76 | const CommandGroup = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
88 | ))
89 |
90 | CommandGroup.displayName = CommandPrimitive.Group.displayName
91 |
92 | const CommandSeparator = React.forwardRef<
93 | React.ElementRef,
94 | React.ComponentPropsWithoutRef
95 | >(({ className, ...props }, ref) => (
96 |
101 | ))
102 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
103 |
104 | const CommandItem = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ className, ...props }, ref) => (
108 |
116 | ))
117 |
118 | CommandItem.displayName = CommandPrimitive.Item.displayName
119 |
120 | const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => {
121 | return
122 | }
123 | CommandShortcut.displayName = 'CommandShortcut'
124 |
125 | export {
126 | Command,
127 | CommandDialog,
128 | CommandInput,
129 | CommandList,
130 | CommandEmpty,
131 | CommandGroup,
132 | CommandItem,
133 | CommandShortcut,
134 | CommandSeparator,
135 | }
136 |
--------------------------------------------------------------------------------
/lib/openai/prompt.ts:
--------------------------------------------------------------------------------
1 | import { limitTranscriptByteLength } from '~/lib/openai/getSmallSizeTranscripts'
2 | import { VideoConfig } from '~/lib/types'
3 | import { DEFAULT_LANGUAGE, PROMPT_LANGUAGE_MAP } from '~/utils/constants/language'
4 |
5 | interface PromptConfig {
6 | language?: string
7 | sentenceCount?: string
8 | shouldShowTimestamp?: boolean
9 | }
10 |
11 | export function getExamplePrompt() {
12 | return {
13 | input: `标题: "【BiliGPT】AI 自动总结 B站 视频内容,GPT-3 智能提取并总结字幕"
14 | 视频字幕: "2.06 - 哈喽哈喽 这里是机密的频道 今天给大家整个活叫哔哩哔哩gp t 6.71 - 选择插着gp t的爆火 作为软件工程师的我也按捺不住 去需要把哔哩哔哩的url贴进来 21.04 - 然后你就点击一键总结 稍等片刻 你就可以获得这样一份精简的总结`,
15 | output: `视频概述:BiliGPT 是一款自动总结B站视频内容的 AI 工具
16 |
17 | - 2.06 - 作为软件工程师的我按捺不住去开发了 BiliGPT
18 | - 21.04 - 只需要粘贴哔哩哔哩的URL,一键总结为精简内容`,
19 | }
20 | }
21 |
22 | export function getSystemPrompt(promptConfig: PromptConfig) {
23 | // [gpt-3-youtube-summarizer/main.py at main · tfukaza/gpt-3-youtube-summarizer](https://github.com/tfukaza/gpt-3-youtube-summarizer/blob/main/main.py)
24 | console.log('prompt config: ', promptConfig)
25 | const { language = '中文', sentenceCount = '5', shouldShowTimestamp } = promptConfig
26 | // @ts-ignore
27 | const enLanguage = PROMPT_LANGUAGE_MAP[language]
28 | // 我希望你是一名专业的视频内容编辑,帮我用${language}总结视频的内容精华。请你将视频字幕文本进行总结(字幕中可能有错别字,如果你发现了错别字请改正),然后以无序列表的方式返回,不要超过5条。记得不要重复句子,确保所有的句子都足够精简,清晰完整,祝你好运!
29 | const betterPrompt = `I want you to act as an educational content creator. You will help students summarize the essence of the video in ${enLanguage}. Please summarize the video subtitles (there may be typos in the subtitles, please correct them) and return them in an unordered list format. Please do not exceed ${sentenceCount} items, and make sure not to repeat any sentences and all sentences are concise, clear, and complete. Good luck!`
30 | // const timestamp = ' ' //`(类似 10:24)`;
31 | // 我希望你是一名专业的视频内容编辑,帮我用${language}总结视频的内容精华。请先用一句简短的话总结视频梗概。然后再请你将视频字幕文本进行总结(字幕中可能有错别字,如果你发现了错别字请改正),在每句话的最前面加上时间戳${timestamp},每句话开头只需要一个开始时间。请你以无序列表的方式返回,请注意不要超过5条哦,确保所有的句子都足够精简,清晰完整,祝你好运!
32 | const promptWithTimestamp = `I would like you to act as a professional video content editor. You will help students summarize the essence of the video in ${enLanguage}. Please start by summarizing the whole video in one short sentence (there may be typos in the subtitles, please correct them). Then, please summarize the video subtitles, each subtitle should has the start timestamp (e.g. 12.4 -) so that students can select the video part. Please return in an unordered list format, make sure not to exceed ${sentenceCount} items and all sentences are concise, clear, and complete. Good luck!`
33 |
34 | return shouldShowTimestamp ? promptWithTimestamp : betterPrompt
35 | }
36 | export function getUserSubtitlePrompt(title: string, transcript: any, videoConfig: VideoConfig) {
37 | const videoTitle = title?.replace(/\n+/g, ' ').trim()
38 | const videoTranscript = limitTranscriptByteLength(transcript).replace(/\n+/g, ' ').trim()
39 | const language = videoConfig.outputLanguage || DEFAULT_LANGUAGE
40 | const sentenceCount = videoConfig.sentenceNumber || 7
41 | const emojiTemplateText = videoConfig.showEmoji ? '[Emoji] ' : ''
42 | const emojiDescriptionText = videoConfig.showEmoji ? 'Choose an appropriate emoji for each bullet point. ' : ''
43 | const shouldShowAsOutline = Number(videoConfig.outlineLevel) > 1
44 | const wordsCount = videoConfig.detailLevel ? (Number(videoConfig.detailLevel) / 100) * 2 : 15
45 | const outlineTemplateText = shouldShowAsOutline ? `\n - Child points` : ''
46 | const outlineDescriptionText = shouldShowAsOutline
47 | ? `Use the outline list, which can have a hierarchical structure of up to ${videoConfig.outlineLevel} levels. `
48 | : ''
49 | const prompt = `Your output should use the following template:\n## Summary\n## Highlights\n- ${emojiTemplateText}Bulletpoint${outlineTemplateText}\n\nYour task is to summarise the text I have given you in up to ${sentenceCount} concise bullet points, starting with a short highlight, each bullet point is at least ${wordsCount} words. ${outlineDescriptionText}${emojiDescriptionText}Use the text above: {{Title}} {{Transcript}}.\n\nReply in ${language} Language.`
50 |
51 | return `Title: "${videoTitle}"\nTranscript: "${videoTranscript}"\n\nInstructions: ${prompt}`
52 | }
53 |
54 | export function getUserSubtitleWithTimestampPrompt(title: string, transcript: any, videoConfig: VideoConfig) {
55 | const videoTitle = title?.replace(/\n+/g, ' ').trim()
56 | const videoTranscript = limitTranscriptByteLength(transcript).replace(/\n+/g, ' ').trim()
57 | const language = videoConfig.outputLanguage || DEFAULT_LANGUAGE
58 | const sentenceCount = videoConfig.sentenceNumber || 7
59 | const emojiTemplateText = videoConfig.showEmoji ? '[Emoji] ' : ''
60 | const wordsCount = videoConfig.detailLevel ? (Number(videoConfig.detailLevel) / 100) * 2 : 15
61 | const promptWithTimestamp = `Act as the author and provide exactly ${sentenceCount} bullet points for the text transcript given in the format [seconds] - [text] \nMake sure that:\n - Please start by summarizing the whole video in one short sentence\n - Then, please summarize with each bullet_point is at least ${wordsCount} words\n - each bullet_point start with \"- \" or a number or a bullet point symbol\n - each bullet_point should has the start timestamp, use this template: - seconds - ${emojiTemplateText}[bullet_point]\n - there may be typos in the subtitles, please correct them\n - Reply all in ${language} Language.`
62 | const videoTranscripts = limitTranscriptByteLength(JSON.stringify(videoTranscript))
63 | return `Title: ${videoTitle}\nTranscript: ${videoTranscripts}\n\nInstructions: ${promptWithTimestamp}`
64 | }
65 |
--------------------------------------------------------------------------------
/components/sidebar.tsx:
--------------------------------------------------------------------------------
1 | export function Sidebar() {
2 | return (
3 | <>
4 |
26 |
27 |
120 | >
121 | )
122 | }
123 |
--------------------------------------------------------------------------------
/pages/[...slug].tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from '@hookform/resolvers/zod'
2 | import getVideoId from 'get-video-id'
3 | import type { NextPage } from 'next'
4 | import { useSearchParams } from 'next/navigation'
5 | import { useRouter } from 'next/router'
6 | import React, { useEffect, useState } from 'react'
7 | import { SubmitHandler, useForm } from 'react-hook-form'
8 | import useFormPersist from 'react-hook-form-persist'
9 | import { useAnalytics } from '~/components/context/analytics'
10 | import { PromptOptions } from '~/components/PromptOptions'
11 | import { SubmitButton } from '~/components/SubmitButton'
12 | import { SummaryResult } from '~/components/SummaryResult'
13 | import { TypingSlogan } from '~/components/TypingSlogan'
14 | import { UsageAction } from '~/components/UsageAction'
15 | import { UsageDescription } from '~/components/UsageDescription'
16 | import { UserKeyInput } from '~/components/UserKeyInput'
17 | import { useToast } from '~/hooks/use-toast'
18 | import { useLocalStorage } from '~/hooks/useLocalStorage'
19 | import { useSummarize } from '~/hooks/useSummarize'
20 | import { VideoService } from '~/lib/types'
21 | import { DEFAULT_LANGUAGE } from '~/utils/constants/language'
22 | import { extractPage, extractUrl } from '~/utils/extractUrl'
23 | import { getVideoIdFromUrl } from '~/utils/getVideoIdFromUrl'
24 | import { VideoConfigSchema, videoConfigSchema } from '~/utils/schemas/video'
25 |
26 | export const Home: NextPage<{
27 | showSingIn: (show: boolean) => void
28 | }> = ({ showSingIn }) => {
29 | const router = useRouter()
30 | const urlState = router.query.slug
31 | const searchParams = useSearchParams()
32 | const licenseKey = searchParams.get('license_key')
33 |
34 | const {
35 | register,
36 | handleSubmit,
37 | control,
38 | trigger,
39 | getValues,
40 | watch,
41 | setValue,
42 | formState: { errors },
43 | } = useForm({
44 | defaultValues: {
45 | enableStream: true,
46 | showTimestamp: false,
47 | showEmoji: true,
48 | detailLevel: 600,
49 | sentenceNumber: 5,
50 | outlineLevel: 1,
51 | outputLanguage: DEFAULT_LANGUAGE,
52 | },
53 | resolver: zodResolver(videoConfigSchema),
54 | })
55 |
56 | // TODO: add mobx or state manager
57 | const [currentVideoId, setCurrentVideoId] = useState('')
58 | const [currentVideoUrl, setCurrentVideoUrl] = useState('')
59 | const [userKey, setUserKey] = useLocalStorage('user-openai-apikey')
60 | const { loading, summary, resetSummary, summarize } = useSummarize(showSingIn, getValues('enableStream'))
61 | const { toast } = useToast()
62 | const { analytics } = useAnalytics()
63 |
64 | useFormPersist('video-summary-config-storage', {
65 | watch,
66 | setValue,
67 | storage: typeof window !== 'undefined' ? window.localStorage : undefined, // default window.sessionStorage
68 | // exclude: ['baz']
69 | })
70 | const shouldShowTimestamp = getValues('showTimestamp')
71 |
72 | useEffect(() => {
73 | licenseKey && setUserKey(licenseKey)
74 | }, [licenseKey])
75 |
76 | useEffect(() => {
77 | // https://www.youtube.com/watch?v=DHhOgWPKIKU
78 | // todo: support redirect from www.youtube.jimmylv.cn/watch?v=DHhOgWPKIKU
79 | const validatedUrl = getVideoIdFromUrl(router.isReady, currentVideoUrl, urlState, searchParams)
80 |
81 | console.log('getVideoUrlFromUrl', validatedUrl)
82 |
83 | validatedUrl && generateSummary(validatedUrl)
84 | }, [router.isReady, urlState, searchParams])
85 |
86 | const validateUrlFromAddressBar = (url?: string) => {
87 | // note: auto refactor by ChatGPT
88 | const videoUrl = url || currentVideoUrl
89 | if (
90 | // https://www.bilibili.com/video/BV1AL4y1j7RY
91 | // https://www.bilibili.com/video/BV1854y1u7B8/?p=6
92 | // https://www.bilibili.com/video/av352747000
93 | // todo: b23.tv url with title
94 | // todo: any article url
95 | !(videoUrl.includes('bilibili.com/video') || videoUrl.includes('youtube.com'))
96 | ) {
97 | toast({
98 | title: '暂不支持此视频链接',
99 | description: '请输入哔哩哔哩或YouTub视频链接,已支持b23.tv短链接',
100 | })
101 | return
102 | }
103 |
104 | // 来自输入框
105 | if (!url) {
106 | // -> '/video/BV12Y4y127rj'
107 | const curUrl = String(videoUrl.split('.com')[1])
108 | router.replace(curUrl)
109 | } else {
110 | setCurrentVideoUrl(videoUrl)
111 | }
112 | }
113 | const generateSummary = async (url?: string) => {
114 | const formValues = getValues()
115 | console.log('=======formValues=========', formValues)
116 |
117 | resetSummary()
118 | validateUrlFromAddressBar(url)
119 |
120 | const videoUrl = url || currentVideoUrl
121 | const { id, service } = getVideoId(videoUrl)
122 | if (service === VideoService.Youtube && id) {
123 | setCurrentVideoId(id)
124 | await summarize(
125 | { videoId: id, service: VideoService.Youtube, ...formValues },
126 | { userKey, shouldShowTimestamp: shouldShowTimestamp },
127 | )
128 | return
129 | }
130 |
131 | const videoId = extractUrl(videoUrl)
132 | if (!videoId) {
133 | return
134 | }
135 |
136 | const pageNumber = extractPage(currentVideoUrl, searchParams)
137 | setCurrentVideoId(videoId)
138 | await summarize(
139 | { service: VideoService.Bilibili, videoId, pageNumber, ...formValues },
140 | { userKey, shouldShowTimestamp },
141 | )
142 | setTimeout(() => {
143 | window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
144 | }, 10)
145 | }
146 | const onFormSubmit: SubmitHandler = async (data) => {
147 | // e.preventDefault();
148 | await generateSummary(currentVideoUrl)
149 | analytics.track('GenerateButton Clicked')
150 | }
151 | const handleApiKeyChange = (e: any) => {
152 | setUserKey(e.target.value)
153 | }
154 |
155 | const handleInputChange = async (e: any) => {
156 | const value = e.target.value
157 | // todo: 兼容?query参数
158 | const regex = /((?:https?:\/\/|www\.)\S+)/g
159 | const matches = value.match(regex)
160 | if (matches && matches[0].includes('b23.tv')) {
161 | toast({ title: '正在自动转换此视频链接...' })
162 | const response = await fetch(`/api/b23tv?url=${matches[0]}`)
163 | const json = await response.json()
164 | setCurrentVideoUrl(json.url)
165 | } else {
166 | setCurrentVideoUrl(value)
167 | }
168 | }
169 |
170 | return (
171 |
172 |
173 |
174 |
175 |
176 |
187 | {summary && (
188 |
194 | )}
195 |
196 | )
197 | }
198 |
199 | export default Home
200 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
5 | import { Check, ChevronRight, Circle } from 'lucide-react'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
41 |
42 | const DropdownMenuSubContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, ...props }, ref) => (
46 |
54 | ))
55 | DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
56 |
57 | const DropdownMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, sideOffset = 4, ...props }, ref) => (
61 |
62 |
71 |
72 | ))
73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
74 |
75 | const DropdownMenuItem = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef & {
78 | inset?: boolean
79 | }
80 | >(({ className, inset, ...props }, ref) => (
81 |
90 | ))
91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
92 |
93 | const DropdownMenuCheckboxItem = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, children, checked, ...props }, ref) => (
97 |
106 |
107 |
108 |
109 |
110 |
111 | {children}
112 |
113 | ))
114 | DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
115 |
116 | const DropdownMenuRadioItem = React.forwardRef<
117 | React.ElementRef,
118 | React.ComponentPropsWithoutRef
119 | >(({ className, children, ...props }, ref) => (
120 |
128 |
129 |
130 |
131 |
132 |
133 | {children}
134 |
135 | ))
136 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
137 |
138 | const DropdownMenuLabel = React.forwardRef<
139 | React.ElementRef,
140 | React.ComponentPropsWithoutRef & {
141 | inset?: boolean
142 | }
143 | >(({ className, inset, ...props }, ref) => (
144 |
149 | ))
150 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
151 |
152 | const DropdownMenuSeparator = React.forwardRef<
153 | React.ElementRef,
154 | React.ComponentPropsWithoutRef
155 | >(({ className, ...props }, ref) => (
156 |
161 | ))
162 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
163 |
164 | const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
165 | return
166 | }
167 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
168 |
169 | export {
170 | DropdownMenu,
171 | DropdownMenuTrigger,
172 | DropdownMenuContent,
173 | DropdownMenuItem,
174 | DropdownMenuCheckboxItem,
175 | DropdownMenuRadioItem,
176 | DropdownMenuLabel,
177 | DropdownMenuSeparator,
178 | DropdownMenuShortcut,
179 | DropdownMenuGroup,
180 | DropdownMenuPortal,
181 | DropdownMenuSub,
182 | DropdownMenuSubContent,
183 | DropdownMenuSubTrigger,
184 | DropdownMenuRadioGroup,
185 | }
186 |
--------------------------------------------------------------------------------