├── .eslintrc.json ├── app ├── favicon.ico ├── components │ ├── SvgComps │ │ ├── EnterSvg.tsx │ │ ├── Search.tsx │ │ ├── BotSvg.tsx │ │ └── loading.tsx │ ├── Copy.tsx │ ├── GithubButton.tsx │ ├── YoutubeVideoComponent.tsx │ └── ActionComponent.tsx ├── layout.tsx ├── page.tsx ├── api │ ├── content │ │ └── route.ts │ ├── new │ │ └── route.ts │ └── chat │ │ └── route.ts ├── tweet │ └── [id] │ │ └── page.tsx ├── blog │ └── [id] │ │ └── page.tsx ├── globals.css └── chat │ └── [id] │ └── page.tsx ├── vercel.json ├── next.config.js ├── postcss.config.js ├── .env.example ├── prisma ├── db.ts └── schema.prisma ├── Dockerfile ├── .gitignore ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── tailwind.config.js ├── LICENSE ├── package.json ├── README.md └── utils ├── initDb.js └── index.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vgulerianb/crucible/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "app/api/**/*": { 4 | "maxDuration": 300 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=sk-123 2 | SQL_CONNECTION_STRING=postgres://postgres:----:6543/postgres?pgbouncer=true 3 | NEXT_PUBLIC_SUPABASE_URL=https://12222.supabase.co 4 | SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.------ 5 | -------------------------------------------------------------------------------- /prisma/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const globalForPrisma = globalThis as unknown as { 4 | prisma: PrismaClient | undefined; 5 | }; 6 | 7 | export const prisma = globalForPrisma.prisma ?? new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts as builder 2 | 3 | WORKDIR /docnavigator 4 | 5 | COPY package.json yarn.lock ./ 6 | RUN yarn install --frozen-lockfile 7 | 8 | COPY . . 9 | RUN yarn build 10 | 11 | FROM node:lts 12 | 13 | WORKDIR /docnavigator 14 | 15 | COPY --from=builder /docnavigator . 16 | 17 | # Copy .env if it exists 18 | # COPY --from=builder /docnavigator/.env* ./ 19 | 20 | EXPOSE 3000 21 | 22 | CMD ["yarn", "start"] 23 | -------------------------------------------------------------------------------- /.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 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | .env 37 | -------------------------------------------------------------------------------- /app/components/SvgComps/EnterSvg.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Icon() { 4 | return ( 5 | 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | export default Icon; 24 | -------------------------------------------------------------------------------- /app/components/Copy.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | const CopyComp = ({ text }: { text: string }) => { 4 | return ( 5 | 15 | ); 16 | }; 17 | 18 | export default CopyComp; 19 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import type { Metadata } from "next"; 3 | import { Inter } from "next/font/google"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Crucible - Copilot for your videos", 9 | description: 10 | "Crucible is a tool that helps you to chat with your video and convert your videos into blogs, twitter threads, and more.", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import { YoutubeVideoComponent } from "./components/YoutubeVideoComponent"; 4 | import { ActionComponent } from "./components/ActionComponent"; 5 | 6 | export default function Home() { 7 | const [sessionId, setSessionId] = useState(""); 8 | 9 | return ( 10 |
11 |
12 | {!sessionId ? ( 13 | 14 | ) : ( 15 | 16 | )} 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /app/components/SvgComps/Search.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const SvgSearch = (props) => ( 4 | 12 | 19 | 26 | 27 | ); 28 | 29 | export default SvgSearch; 30 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: "class", 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | keyframes: { 17 | fadeIn: { 18 | "0%": { opacity: 0 }, 19 | "100%": { opacity: 1 }, 20 | }, 21 | fadeOut: { 22 | "0%": { opacity: 1 }, 23 | "100%": { 24 | opacity: 0, 25 | }, 26 | }, 27 | }, 28 | animation: { 29 | fadeIn: "fadeIn 0.5s ease-in-out", 30 | fadeOut: "fadeOut 0.5s ease-in-out", 31 | }, 32 | }, 33 | }, 34 | plugins: [], 35 | }; 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vikrant Guleria 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/components/SvgComps/BotSvg.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Icon() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export default Icon; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crucible", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "prisma generate && next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "engines": { 12 | "node": ">=18.0.0" 13 | }, 14 | "dependencies": { 15 | "@prisma/client": "^5.0.0", 16 | "@supabase/supabase-js": "^2.26.0", 17 | "@types/node": "20.4.3", 18 | "@types/react": "18.2.15", 19 | "@types/react-dom": "18.2.7", 20 | "ai": "^2.1.25", 21 | "autoprefixer": "10.4.14", 22 | "axios": "^1.4.0", 23 | "eslint": "8.45.0", 24 | "eslint-config-next": "13.4.12", 25 | "gpt-tokenizer": "^2.1.1", 26 | "next": "13.4.12", 27 | "openai-edge": "^1.2.1", 28 | "postcss": "8.4.27", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-markdown": "^8.0.7", 32 | "rehype-raw": "^6.1.1", 33 | "remark-gfm": "^3.0.1", 34 | "supabase": "^1.77.9", 35 | "tailwindcss": "3.3.3", 36 | "typescript": "5.1.6", 37 | "uuid": "^9.0.0", 38 | "youtube-caption-extractor": "^1.4.3", 39 | "youtube-captions-scraper": "^2.0.0" 40 | }, 41 | "devDependencies": { 42 | "prisma": "^5.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/content/route.ts: -------------------------------------------------------------------------------- 1 | import { openAiHandler } from "@/utils"; 2 | import { NextResponse } from "next/server"; 3 | import { prisma } from "../../../prisma/db"; 4 | 5 | export async function POST(req: Request) { 6 | const request = await req.json(); 7 | 8 | const video = await prisma.videos.findFirst({ 9 | where: { 10 | session_id: request?.sessionId, 11 | }, 12 | select: { 13 | content: true, 14 | }, 15 | }); 16 | let blog; 17 | let tweet; 18 | if (request?.variant === "tweet") { 19 | tweet = await getTweet(video?.content ?? ""); 20 | if (!tweet?.length) 21 | return new Response("Something went wrong", { 22 | status: 400, 23 | }); 24 | } else { 25 | blog = await openAiHandler( 26 | video?.content ?? "", 27 | "blog", 28 | request?.instructions 29 | ); 30 | } 31 | await prisma.generations.create({ 32 | data: { 33 | blog: blog, 34 | thread: tweet, 35 | session_id: request?.sessionId, 36 | }, 37 | }); 38 | return NextResponse.json({ status: true }); 39 | } 40 | 41 | const getTweet = async (content: string, count = 1) => { 42 | if (count === 4) return []; 43 | let tweet = await openAiHandler(content ?? "", "tweet"); 44 | try { 45 | tweet = JSON.parse(tweet); 46 | } catch (e) { 47 | console.log("Retry", count); 48 | tweet = getTweet(content, count + 1); 49 | } 50 | return tweet; 51 | }; 52 | -------------------------------------------------------------------------------- /app/api/new/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { prisma } from "../../../prisma/db"; 3 | // import { getSubtitles } from "youtube-captions-scraper"; 4 | import { initDb } from "../../../utils/initDb"; 5 | import { getSubtitles } from "youtube-caption-extractor"; 6 | 7 | const uuid = require("uuid"); 8 | 9 | export async function POST(req: Request) { 10 | const request = await req.json(); 11 | if (!request?.url) 12 | return new Response("Something went wrong", { 13 | status: 400, 14 | }); 15 | const urlObj = new URL(request?.url); 16 | const youtubeVideoId = urlObj.searchParams.get("v"); 17 | const sessionId = uuid.v4() + "_" + new Date().getTime(); 18 | let content = (await getSubtitles({ 19 | videoID: youtubeVideoId, 20 | }).catch((err: any) => { 21 | console.log(err); 22 | })) as any; 23 | if (!content?.length) 24 | return new Response("Something went wrong", { 25 | status: 400, 26 | }); 27 | content = content.map((c: any) => c.text).join(" "); 28 | const videoStatus = await prisma.videos 29 | .create({ 30 | data: { 31 | url: request.url, 32 | session_id: sessionId, 33 | content, 34 | }, 35 | }) 36 | .catch(async (err) => { 37 | console.log(err); 38 | if (err.code === "P2021") { 39 | await initDb(); 40 | } 41 | }); 42 | console.log({ videoStatus }); 43 | if (videoStatus) return NextResponse.json({ sessionId }); 44 | return new Response("Something went wrong", { 45 | status: 400, 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("SQL_CONNECTION_STRING") 8 | } 9 | 10 | /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. 11 | model conversations { 12 | id BigInt @id @default(autoincrement()) 13 | created_at DateTime? @default(now()) @db.Timestamptz(6) 14 | session_id String? 15 | query String? 16 | response String? 17 | videos videos? @relation(fields: [session_id], references: [session_id], onDelete: NoAction, onUpdate: NoAction) 18 | } 19 | 20 | /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. 21 | model generations { 22 | id BigInt @id @default(autoincrement()) 23 | created_at DateTime? @default(now()) @db.Timestamptz(6) 24 | blog String? 25 | thread Json? @db.Json 26 | session_id String? 27 | videos videos? @relation(fields: [session_id], references: [session_id], onDelete: NoAction, onUpdate: NoAction) 28 | } 29 | 30 | /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. 31 | model videos { 32 | id BigInt @id @default(autoincrement()) 33 | created_at DateTime? @default(now()) @db.Timestamptz(6) 34 | url String? 35 | content String? 36 | session_id String? @unique 37 | conversations conversations[] 38 | generations generations[] 39 | } 40 | -------------------------------------------------------------------------------- /app/components/GithubButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | export const GithubButton = () => { 3 | return ( 4 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crucible | Copilot for YouTube 2 | 3 | Crucible is an amazing tool that serves as a copilot for YouTube. It is designed to enhance your YouTube experience by providing a range of services like generating blogs, creating Twitter threads from YouTube videos, and even enabling users to chat with YouTube videos. 4 | ![ezgif-2-ee894b92b5](https://github.com/vgulerianb/crucible/assets/90599235/356cc38a-2f4f-419e-9c07-89a045fe1a16) 5 | 6 | ## Features 7 | 8 | - **Blog Generation:** Crucible can convert your favorite YouTube videos into readable blogs. Whether it's educational content, discussion panels, interviews, or any other genre, Crucible transforms video content into written format so you can revisit the content at your own pace. 9 | 10 | - **Twitter Thread Creation:** Are you keen on sharing insights from a video you just watched on YouTube? Crucible can help you create a Twitter thread from a YouTube video. This unique feature allows you to share valuable content with your Twitter following without the hassle of transcribing and summarizing the content. 11 | 12 | - **Chat with YouTube Videos:** One of the most innovative features of Crucible is its ability to enable users to chat with YouTube videos. This feature creates an interactive experience, making YouTube consumption more engaging and enjoyable. 13 | 14 | ## Contributions 15 | 16 | We appreciate contributions from the community. If you are interested in contributing, please feel free to make a pull request. 17 | 18 | ## Contact Us 19 | 20 | Have any questions or feedback? We’d love to hear from you. Contact us at support@docnavigator.in. 21 | 22 | _Please note that Crucible is not endorsed by YouTube, Google, or Twitter. These are trademarks of their respective companies._ 23 | -------------------------------------------------------------------------------- /app/tweet/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { GithubButton } from "../../components/GithubButton"; 3 | import { prisma } from "../../../prisma/db"; 4 | 5 | async function getTweet(id: string) { 6 | const generations = await prisma.generations.findFirst({ 7 | where: { 8 | session_id: id, 9 | }, 10 | select: { 11 | thread: true, 12 | }, 13 | orderBy: { 14 | created_at: "desc", 15 | }, 16 | take: 1, 17 | }); 18 | return generations?.thread || undefined; 19 | } 20 | 21 | export default async function Tweet({ params }: { params: { id: string } }) { 22 | const tweets = (await getTweet(params?.id)) as string[]; 23 | 24 | return ( 25 |
26 |
27 |
28 |

29 | Twitter Thread 30 |

31 | 32 |
33 | 34 | {tweets && tweets?.length ? ( 35 | tweets?.map((tweet, key) => ( 36 | 42 | {tweet} 43 | 44 | )) 45 | ) : ( 46 |
47 | Threads not found. If you believe this is an error, please try 48 | refreshing the page or raise an issue on Github. 49 |
50 | )} 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/blog/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import remarkGfm from "remark-gfm"; 2 | import ReactMarkdown from "react-markdown"; 3 | import rehype from "rehype-raw"; 4 | import { GithubButton } from "../../components/GithubButton"; 5 | import CopyComp from "@/app/components/Copy"; 6 | import { prisma } from "../../../prisma/db"; 7 | 8 | async function getBlog(id: string) { 9 | const generations = await prisma.generations.findFirst({ 10 | where: { 11 | session_id: id, 12 | }, 13 | select: { 14 | blog: true, 15 | }, 16 | orderBy: { 17 | created_at: "desc", 18 | }, 19 | take: 1, 20 | }); 21 | return generations?.blog || undefined; 22 | } 23 | 24 | export default async function Tweet({ params }: { params: { id: string } }) { 25 | const blog = await getBlog(params?.id); 26 | return ( 27 |
28 |
29 |
30 |

31 | Generated Blog 32 |

33 | 34 |
35 | 36 |
37 | {blog ? ( 38 | <> 39 | 40 | 45 | {blog} 46 | 47 | 48 | ) : ( 49 | <> 50 | Blog not found. If you believe this is an error, please try 51 | refreshing the page or raise an issue on Github. 52 | 53 | )} 54 |
55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /utils/initDb.js: -------------------------------------------------------------------------------- 1 | export const initDb = async (prismaConn) => { 2 | const { prisma } = await require("./../prisma/db"); 3 | try { 4 | await prisma.$queryRaw`CREATE TABLE "conversations" ( 5 | "id" BIGSERIAL NOT NULL, 6 | "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, 7 | "session_id" TEXT, 8 | "query" TEXT, 9 | "response" TEXT, 10 | 11 | CONSTRAINT "conversations_pkey" PRIMARY KEY ("id") 12 | );`; 13 | } catch (e) { 14 | console.log("Table already exists"); 15 | } 16 | try { 17 | await prisma.$queryRaw`CREATE TABLE "generations" ( 18 | "id" BIGSERIAL NOT NULL, 19 | "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, 20 | "blog" TEXT, 21 | "thread" JSON, 22 | "session_id" TEXT, 23 | 24 | CONSTRAINT "generations_pkey" PRIMARY KEY ("id") 25 | );`; 26 | } catch (e) { 27 | console.log("Table already exists"); 28 | } 29 | try { 30 | await prisma.$queryRaw`CREATE TABLE "videos" ( 31 | "id" BIGSERIAL NOT NULL, 32 | "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, 33 | "url" TEXT, 34 | "content" TEXT, 35 | "session_id" TEXT, 36 | 37 | CONSTRAINT "videos_pkey" PRIMARY KEY ("id") 38 | );`; 39 | } catch (e) { 40 | console.log("Table already exists"); 41 | } 42 | 43 | try { 44 | await prisma.$queryRaw`CREATE UNIQUE INDEX "videos_session_id_key" ON "videos"("session_id");`; 45 | } catch (e) { 46 | console.log("Table already exists"); 47 | } 48 | 49 | try { 50 | await prisma.$queryRaw`ALTER TABLE "conversations" ADD CONSTRAINT "conversations_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "videos"("session_id") ON DELETE NO ACTION ON UPDATE NO ACTION;`; 51 | } catch (e) { 52 | console.log("Table already exists"); 53 | } 54 | 55 | try { 56 | await prisma.$queryRaw`ALTER TABLE "generations" ADD CONSTRAINT "generations_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "videos"("session_id") ON DELETE NO ACTION ON UPDATE NO ACTION;`; 57 | } catch (e) { 58 | console.log("Table already exists"); 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 255, 255, 255; 7 | --background-start-rgb: 0, 0, 0; 8 | --background-end-rgb: 0, 0, 0; 9 | } 10 | 11 | body { 12 | color: rgb(var(--foreground-rgb)); 13 | background: linear-gradient( 14 | to bottom, 15 | transparent, 16 | rgb(var(--background-end-rgb)) 17 | ) 18 | rgb(var(--background-start-rgb)); 19 | } 20 | 21 | .markdownHolder { 22 | font-size: 16px; 23 | } 24 | 25 | .markdownHolder p { 26 | @apply mb-[8px]; 27 | } 28 | 29 | /* if p is inside li */ 30 | .markdownHolder li p { 31 | @apply mb-[0px]; 32 | } 33 | 34 | .markdownHolder ol { 35 | @apply list-inside list-decimal; 36 | } 37 | 38 | .markdownHolder ul { 39 | @apply list-inside list-disc; 40 | } 41 | 42 | .markdownHolder h1 { 43 | @apply text-xl font-bold mb-[8px]; 44 | } 45 | 46 | .markdownHolder h2 { 47 | @apply text-lg font-bold mb-[8px]; 48 | } 49 | 50 | .markdownHolder h3 { 51 | @apply text-base font-bold mb-[8px]; 52 | } 53 | 54 | .markdownHolder h4, 55 | .markdownHolder h6, 56 | .markdownHolder h6 { 57 | @apply text-base font-bold mb-[8px]; 58 | } 59 | 60 | code { 61 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 62 | monospace; 63 | } 64 | pre { 65 | margin: 0.5em 0; 66 | overflow: auto; 67 | border-radius: 0.3em; 68 | color: #fff; 69 | font-size: 13px !important; 70 | } 71 | 72 | :not(pre) > code { 73 | background-color: #222a39 !important; 74 | color: #fff !important; 75 | padding: 2px !important; 76 | border-radius: 2px !important; 77 | } 78 | .markdownHolder { 79 | font-size: 16px !important; 80 | padding-top: 4px !important; 81 | } 82 | .markdownHolder p { 83 | margin: 0px; 84 | } 85 | @media (min-width: 768px) { 86 | .inset-0 { 87 | inset: 0px !important; 88 | } 89 | } 90 | ul { 91 | padding: 0px; 92 | list-style-type: disc; 93 | list-style-position: inside; 94 | } 95 | 96 | ol { 97 | padding: 0px; 98 | list-style: decimal; 99 | list-style-position: inside; 100 | } 101 | blockquote { 102 | margin: 0px; 103 | padding: 4px 0px; 104 | font-size: 12px; 105 | font-weight: 500; 106 | } 107 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, OpenAIApi } from "openai-edge"; 2 | import { OpenAIStream, StreamingTextResponse } from "ai"; 3 | import { createClient } from "@supabase/supabase-js"; 4 | 5 | const supabaseClient = createClient( 6 | process.env.NEXT_PUBLIC_SUPABASE_URL ?? "", 7 | process.env.SUPABASE_SERVICE_ROLE_KEY ?? "" 8 | ); 9 | 10 | const config = new Configuration({ 11 | apiKey: process.env.OPENAI_API_KEY, 12 | }); 13 | const openai = new OpenAIApi(config); 14 | 15 | export const runtime = "edge"; 16 | 17 | export async function POST(req: Request) { 18 | const request = await req.json(); 19 | const { prompt, sessionId, previous } = request; 20 | 21 | if (!prompt || !sessionId) { 22 | return new Response("Invalid session"); 23 | } 24 | if (previous && previous?.length > 4) { 25 | previous.splice(0, previous.length - 4); 26 | } 27 | const videoContent = await supabaseClient 28 | .from("videos") 29 | .select("content") 30 | .eq("session_id", sessionId); 31 | 32 | try { 33 | const response = await openai.createChatCompletion({ 34 | model: "gpt-3.5-turbo-16k", 35 | stream: true, 36 | temperature: 0.3, 37 | messages: [ 38 | { 39 | role: "system", 40 | content: `You a are nice and helpful human educator. Given the following surfaced documents and a question, create a Final answer in markdown. Try to provide concise code snippets for coding questions. 41 | Document Content: 42 | ${videoContent?.data?.[0]?.content} 43 | `, 44 | }, 45 | ...(previous?.length 46 | ? previous?.map((prev: any) => ({ 47 | role: prev.isSender ? "user" : "system", 48 | content: prev.msg, 49 | })) 50 | : []), 51 | { 52 | role: "user", 53 | content: prompt, 54 | }, 55 | ], 56 | }); 57 | const stream = OpenAIStream(response, { 58 | onCompletion: async (completion: string) => { 59 | await supabaseClient.from("conversations").insert({ 60 | query: prompt, 61 | response: completion, 62 | session_id: sessionId, 63 | }); 64 | }, 65 | }); 66 | console.log({ stream: "" }); 67 | return new StreamingTextResponse(stream); 68 | } catch (e) { 69 | console.log(e); 70 | return new Response("Something went wrong"); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/components/SvgComps/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Icon() { 4 | return ( 5 | 12 | 13 | 14 | 22 | 30 | 31 | 32 | 40 | 48 | 49 | 50 | 58 | 66 | 67 | 68 | 69 | 74 | 75 | 83 | 84 | 85 | 93 | 94 | 95 | 96 | ); 97 | } 98 | 99 | export default Icon; 100 | -------------------------------------------------------------------------------- /app/components/YoutubeVideoComponent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import axios from "axios"; 4 | import Loading from "../components/SvgComps/loading"; 5 | import { GithubButton } from "./GithubButton"; 6 | 7 | export const YoutubeVideoComponent = ({ 8 | setSessionId, 9 | }: { 10 | setSessionId: (sessionId: string) => void; 11 | }) => { 12 | const [url, setUrl] = useState(""); 13 | const [loading, setLoading] = useState(false); 14 | 15 | const handleSubmit = () => { 16 | const regex = 17 | /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(.+)/g; 18 | const match = regex.exec(url); 19 | if (!match) { 20 | alert("Invalid URL"); 21 | return; 22 | } 23 | setLoading(true); 24 | 25 | axios 26 | .post("/api/new", { 27 | url: url, 28 | }) 29 | .then((res) => { 30 | setSessionId(res.data.sessionId); 31 | }) 32 | .catch(() => { 33 | alert("Unable to process given video, try another one."); 34 | }) 35 | .finally(() => { 36 | setLoading(false); 37 | }); 38 | }; 39 | 40 | return ( 41 | <> 42 |

43 | Welcome to Crucible 44 |

45 |
46 |
47 | 50 | setUrl(e.target.value)} 53 | type="text" 54 | id="url" 55 | className="border text-sm rounded-lg block w-full p-2.5 bg-gray-700 border-gray-600 placeholder-gray-400 text-white focus:ring-blue-500 focus:border-blue-500" 56 | placeholder="https://www.youtube.com/watch?v=TX9qSaGXFyg&t=23s" 57 | required 58 | /> 59 |
60 | 71 |
72 | 73 |
74 |

Features

75 |
76 |
77 | Generate a twitter thread from a youtube video 78 |
79 |
80 | Generate a blog from a youtube video 81 |
82 |
83 | Chat with the youtube video 84 |
85 |
86 | 87 |
88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | import { decode, encode } from "gpt-tokenizer"; 2 | 3 | const openAiHandler = async ( 4 | query: string, 5 | variant: string, 6 | instructions?: string 7 | ) => { 8 | let encoded = encode(query); 9 | query = ""; 10 | 11 | if (encoded?.length > 10000) { 12 | encoded = encoded.slice(0, 10000); 13 | } 14 | const decoded = await decode(encoded); 15 | const tweetExample = [ 16 | { 17 | role: "user", 18 | content: "Example", 19 | }, 20 | { 21 | role: "assistant", 22 | content: "['Tweet1', 'Tweet2']", 23 | }, 24 | { 25 | role: "user", 26 | content: `A new PostgreSQL extension is now available in Supabase: pgvector, an open-source vector similarity search. The exponential progress of AI functionality over the past year has inspired many new real world applications. One specific challenge has been the ability to store and query embeddings at scale. In this post we'll explain what embeddings are, why we might want to use them, and how we can store and query them in PostgreSQL using pgvector.`, 27 | }, 28 | { 29 | role: "assistant", 30 | content: `["🚀 Exciting news! 🎉 Supabase has just released a new PostgreSQL extension called pgvector! 🐘 It's an open-source vector similarity search that enables storing and querying of embeddings at scale. Let's dive in to learn more about this game-changing technology! #AI #PostgreSQL #pgvector", "🔎 What are embeddings, you ask? Embeddings are dense numerical representations of data that capture its semantic meaning. They have been crucial in various AI applications such as natural language processing and computer vision. #AI #Embeddings #pgvector", "💡 With the exponential progress in AI functionality, the demand for storing and querying embeddings at scale has increased. That's where pgvector comes in. It allows you to efficiently store and search vector embeddings directly in PostgreSQL 🐘. Say goodbye to performance bottlenecks! #pgvector #PostgreSQL", "🔍 Wondering how to get started with pgvector? It's as easy as installing the extension and creating a pgvector column on your table. Just a few simple steps, and you're ready to store and query your vector embeddings at lightning speed! ⚡️ #pgvector #AI #PostgreSQL", "🎯 Whether you're working on recommendation systems, image recognition, or any AI application that requires similarity search, pgvector and Supabase have got your back! Say hello to efficient, scalable, and lightning-fast vector similarity search with pgvector! 🚀 #pgvector #Supabase"]`, 31 | }, 32 | ]; 33 | 34 | const response = await fetch(`https://api.openai.com/v1/chat/completions`, { 35 | method: "POST", 36 | headers: { 37 | "Content-Type": "application/json", 38 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 39 | }, 40 | body: JSON.stringify({ 41 | model: "gpt-3.5-turbo-16k", 42 | messages: [ 43 | { 44 | role: "system", 45 | content: 46 | variant === "blog" 47 | ? `You are a highly efficient assistant specializing in converting user information into engaging, viral SEO-friendly detailed blog posts. Adhering to the key structure of a blog post - including Heading 1, Introduction, Body, and End notes - you weave compelling narratives without deviating from the facts provided in the input text. Please ensure responses are returned in markdown format.` 48 | : `You are a highly efficient assistant that can turn user input into a concise, engaging viral tweet thread. While aiming to include all key information, keep the thread to a maximum of 5 tweets. Please formulate responses in this format ["Tweet1", "Tweet2"].`, 49 | }, 50 | ...(variant === "tweet" ? tweetExample : []), 51 | { 52 | role: "user", 53 | content: `${ 54 | instructions 55 | ? `Instructions given by user: ${instructions}\n Content:\n` 56 | : "" 57 | }${decoded}`, 58 | }, 59 | ], 60 | max_tokens: 1800, 61 | temperature: 0.4, 62 | stream: false, 63 | }), 64 | }); 65 | const data = await response.json(); 66 | 67 | return data?.choices?.[0]?.message?.content ?? null; 68 | }; 69 | 70 | export { openAiHandler }; 71 | -------------------------------------------------------------------------------- /app/components/ActionComponent.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Loading from "../components/SvgComps/loading"; 3 | import axios from "axios"; 4 | import { GithubButton } from "./GithubButton"; 5 | 6 | export const ActionComponent = ({ 7 | setSessionId, 8 | sessionId, 9 | }: { 10 | setSessionId: (sessionId: string) => void; 11 | sessionId: string; 12 | }) => { 13 | const [action, setAction] = useState(""); 14 | const [loading, setLoading] = useState(false); 15 | const [instructions, setInstructions] = useState(""); 16 | 17 | const generateContent = (variant: string) => { 18 | axios 19 | .post("/api/content", { 20 | sessionId: sessionId, 21 | variant, 22 | instructions, 23 | }) 24 | .then(() => { 25 | const a = document.createElement("a"); 26 | a.setAttribute( 27 | "href", 28 | `${window.location.origin}/${variant}/${sessionId}` 29 | ); 30 | a.setAttribute("target", "_blank"); 31 | a.setAttribute("hidden", "true"); 32 | document.body.appendChild(a); 33 | a.click(); 34 | document.body.removeChild(a); 35 | }) 36 | .catch(() => { 37 | alert("Something went wrong, please try again."); 38 | }) 39 | .finally(() => { 40 | setLoading(false); 41 | }); 42 | }; 43 | 44 | const startChat = () => { 45 | const a = document.createElement("a"); 46 | a.setAttribute("href", `${window.location.origin}/chat/${sessionId}`); 47 | a.setAttribute("target", "_blank"); 48 | a.setAttribute("hidden", "true"); 49 | document.body.appendChild(a); 50 | a.click(); 51 | document.body.removeChild(a); 52 | }; 53 | 54 | return ( 55 |
56 |
57 | { 59 | setSessionId(""); 60 | }} 61 | className="text-sm cursor-pointer font-thin" 62 | >{`<- Go Back`} 63 | What would you like to do with this video? 64 |
65 | 74 | 83 | 92 |
93 | {action && action !== "chat" ? ( 94 | <> 95 | 103 | 117 | 118 | ) : action ? ( 119 | 130 | ) : ( 131 | "" 132 | )} 133 |
134 | 135 |
136 | ); 137 | }; 138 | -------------------------------------------------------------------------------- /app/chat/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | import { GithubButton } from "../../components/GithubButton"; 4 | import BotSvg from "../../components/SvgComps/BotSvg"; 5 | import EnterSvg from "../../components/SvgComps/EnterSvg"; 6 | import LoadingSvg from "../../components/SvgComps/loading"; 7 | import ReactMarkdown from "react-markdown"; 8 | import axios from "axios"; 9 | 10 | interface ChatInterface { 11 | msg: string; 12 | isSender: boolean; 13 | } 14 | 15 | export default function Chat(params: any) { 16 | const variant = "dark"; 17 | const [answer, setAnswer] = useState(""); 18 | const [search, setSearch] = useState(""); 19 | const [loading, setLoading] = useState(false); 20 | const [chats, setChats] = useState([ 21 | { 22 | msg: "Hi how can I help you?", 23 | isSender: false, 24 | }, 25 | ]); 26 | 27 | const scrollToBottom = () => { 28 | const chatsHolder = document.querySelector(".chatsHolder"); 29 | if (chatsHolder) { 30 | chatsHolder.scrollTop = chatsHolder.scrollHeight; 31 | } 32 | }; 33 | 34 | const handleSearch = async () => { 35 | if (loading) return; 36 | const searchValue = search.trim(); 37 | setSearch(""); 38 | setChats((prev) => [ 39 | ...prev, 40 | { 41 | msg: search, 42 | isSender: true, 43 | }, 44 | { 45 | msg: "", 46 | isSender: false, 47 | }, 48 | ]); 49 | setLoading(true); 50 | console.log({ params }); 51 | await axios 52 | .post( 53 | "/api/chat", 54 | { 55 | sessionId: params?.params?.id, 56 | prompt: searchValue, 57 | previous: chats.slice(1, chats.length), 58 | }, 59 | { 60 | onDownloadProgress: (progressEvent: any) => { 61 | if (progressEvent?.event?.target?.response) 62 | setAnswer(progressEvent?.event?.target?.response); 63 | }, 64 | } 65 | ) 66 | .then((response) => { 67 | setAnswer(response?.data); 68 | }) 69 | .catch(() => { 70 | setAnswer("Something went wrong, please try again later."); 71 | }); 72 | setLoading(false); 73 | setTimeout(() => { 74 | setAnswer(""); 75 | }); 76 | }; 77 | 78 | useEffect(() => { 79 | scrollToBottom(); 80 | if (answer !== "") { 81 | let tempChats = [...chats]; 82 | tempChats[tempChats.length - 1].msg = answer; 83 | setChats(tempChats); 84 | } 85 | }, [answer, loading]); 86 | 87 | return ( 88 |
89 |
90 |
91 |

92 | Chat with the youtube bot 93 |

94 | 95 |
96 |
{ 98 | e.stopPropagation(); 99 | }} 100 | style={{ 101 | width: "100%", 102 | height: "100%", 103 | maxWidth: "900px", 104 | maxHeight: "100%", 105 | borderRadius: "0.375rem", 106 | backgroundColor: variant === "dark" ? "#262626" : "#f9fafb", 107 | marginLeft: "16px", 108 | marginRight: "16px", 109 | padding: "16px", 110 | display: "flex", 111 | flexDirection: "column", 112 | overflow: "hidden", 113 | position: "relative", 114 | }} 115 | > 116 |
124 | {chats?.map((chat, index) => 125 | answer.length || chat?.msg?.length ? ( 126 |
144 | {!chat?.isSender || (answer && chats.length - 1 === index) ? ( 145 |
154 | 155 |
156 | ) : ( 157 |
173 | 184 | 185 | 186 | 187 |
188 | )} 189 | {!(loading && answer === "") || index !== chats.length - 1 ? ( 190 |
199 | 200 | {answer !== "" && index === chats.length - 1 201 | ? answer 202 | : chat?.msg} 203 | 204 |
205 | ) : ( 206 | "" 207 | )} 208 |
209 | ) : ( 210 | "" 211 | ) 212 | )} 213 | {loading && answer === "" ? ( 214 |
230 | 231 | Searching. This may take a second! 232 |
233 | ) : ( 234 | "" 235 | )} 236 |
237 |
246 |