├── 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 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.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 | 4 | 5 | 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 | 3 | 4 | -------------------------------------------------------------------------------- /.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 |
10 |

11 | 次数用完啦!每天都能用 {RATE_LIMIT_COUNT} 次,请点击 12 | 13 | 14 | analytics.track('ShopLink Clicked')} 18 | > 19 | 点击购买 20 | 21 | 22 | 次数哦,💰 23 |
24 | 或者 25 | 26 | 「加我微信」 27 | 28 |
29 |

30 |
31 | 32 |
33 |
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 | ![img_1.png](public/deploy-ch/img_1.png) 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 | ![img_3.jpg](public/deploy-ch/img_3.jpg) 17 | 根据情况输入基本信息: 18 | ![img_4.png](public/deploy-ch/img_4.png) 19 | 进入该数据库的控制台,下滑到 `REST API` 栏,点击复制`UPSTASH_REDIS_REST_URL`和 `UPSTASH_REDIS_REST_TOKEN`,赋值到同名变量 20 | ![img_5.png](public%2Fdeploy-ch%2Fimg_5.png) 21 | 5. 登录 https://supabase.com/ ,新建一个 project 22 | ![img_6.png](public%2Fdeploy-ch%2Fimg_6.png) 23 | 确认后,点击右侧导航的齿轮进入设置,复制 `URL` 到 `SUPABASE_HOSTNAME`,复制 `key` 到 `NEXT_PUBLIC_SUPABASE_ANON_KEY`。 24 | ![img_7.png](public%2Fdeploy-ch%2Fimg_7.png) 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 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 |

    48 | 49 | {`【📝 总结:${currentVideoId}】`} 50 | 51 |

    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 | 19 | 24 | 25 |

    26 | 请使用自己的 API Key 27 | (每天免费 {RATE_LIMIT_COUNT} 次哦,支持 28 | analytics.track('ShopLink Clicked')} 34 | > 35 | 「购买次数」 36 | 37 | 啦! 38 | 39 | 也可以真的 40 | 「给我打赏」哦 🤣) 41 | 42 |

    43 |
    44 |
    45 | 51 |
    52 |
    53 | 如何获取你自己的 License Key 54 | 60 | https://shop.jimmylv.cn 61 | 62 |
    63 |
    64 |
    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 |
    10 |
    11 |

    +

    12 |
    13 |
    14 |

    +

    15 |
    16 |
    17 |

    +

    18 |
    19 |
    20 |
    21 |

    +

    22 |
    23 |
    24 |
    25 |

    +

    26 |
    27 |
    28 |

    +

    29 |
    30 |
    31 |
    32 |

    +

    33 |
    34 |
    35 |
    36 |

    +

    37 |
    38 |
    39 |

    +

    40 |
    41 |
    42 |

    +

    43 |
    44 |
    45 |

    +

    46 |
    47 |
    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 |
    10 |
    11 |
    12 | 13 |
    14 |
    15 |
    16 |
    17 | 18 |
    19 |
    20 |
    21 |

    +

    22 |
    23 |
    24 |
    25 |

    💺 虚位以待,Coming Soon!

    26 |
    27 |
    28 |
    29 |

    +

    30 |
    31 |
    32 |

    +

    33 |
    34 |
    35 |
    36 |

    +

    37 |
    38 |
    39 |
    40 |

    +

    41 |
    42 |
    43 |

    +

    44 |
    45 |
    46 |

    +

    47 |
    48 |
    49 |

    +

    50 |
    51 |
    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 | [![BibiGPT音视频总结神器](./public/BibiGPT.gif)](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 | ![](./public/wechat.jpg) 71 | 72 | ## Star History 73 | 74 | [![Star History Chart](https://api.star-history.com/svg?repos=JimmyLv/BibiGPT&type=Date)](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 | Logo 29 | 30 |

    登录 BibiGPT

    31 |

    32 | (每天都赠送 {LOGIN_LIMIT_COUNT} 次哦, 33 | analytics.track('ShopLink Clicked')} 37 | > 38 | 点击购买 39 | 40 | 新的次数) 41 |

    42 |

    Input, Prompt, Output

    43 |
    44 | 45 |
    46 | 80 |
    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 |
    16 | 21 | 26 |
    27 | 30 | 41 |
    42 | 43 |
    44 | 48 | 59 |
    60 |
    61 | 65 | 77 |
    78 |
    79 | 83 | 94 |
    95 |
    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 |
    24 | 32 |
    33 |
    34 | 如何获取你自己的 Flomo 专属记录 API 35 | 41 | https://v.flomoapp.com/mine?source=incoming_webhook 42 | 43 |
    44 |
    45 |
    46 |
    47 |
    48 |

    飞书 Webhook

    49 |
    50 | 58 |
    59 |
    60 | 如何获取你自己的飞书 Webhook API 地址 61 | 67 | https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN?lang=zh-CN 68 | 69 |
    70 |
    71 |
    72 |
    73 |
    74 |
    75 |

    Roam (coming soon)

    76 |
    77 |
    78 |

    Notion (coming soon)

    79 |
    80 |
    81 |
    82 |

    💺 虚位以待,欢迎 PR!

    83 |
    84 |
    85 |
    86 |

    +

    87 |
    88 |
    89 |

    +

    90 |
    91 |
    92 |

    +

    93 |
    94 |
    95 |

    +

    96 |
    97 |
    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 |
    14 | 112 |
    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 | 28 | 29 | 30 | {children} 31 | 32 | 33 | 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 |
    177 | 184 | 185 | 186 | 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 | --------------------------------------------------------------------------------