diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..175f95f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +npm-debug.log +Dockerfile +.dockerignore +.git +.gitignore +.env \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3e2a180..d8d4afd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ node_modules .vscode *.swp *.swo -.env +# local env files +.env*.local # credentials .*.json diff --git a/README.md b/README.md index f858cb1..0183f2b 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,53 @@ -# Innovation Studio boilerplate app template - -This boilerplate repo is still under heavy initial development. We are -exercising the process of rapidly spinning up a reproducible application stack, -see doc/system-archictecture.png for the current architecture. - -Goals: -- Minimize technical effort that isn't germane to the ideas we want to test - - Select and develop tools we like as a team and commit to reusing them - - All pods benefit from other pod contributions to the boilerplate - - Re-use effort and learnings from Graceland’s stack - - Quick to spin up for simple demos/prototypes - - Encourage sharing dev effort and cross-contribution across pods with - normalized conventions -- Serve also as a solid foundation for production MVPs and eventual GA releases - - Moco-friendly stack: GCP-based, built on best practices with generic - primitives, not reliant on third parties (e.g. heroku) - - Flexible, minimal, modular architecture that can replace or include new - components as needed (alternate backends, cloud functions, A/B testing, - CDNs, etc) -- Innovation Studio is the first customer, but this might provide value in Moz - more broadly and/or as open source project. +# PROTO +This is a prototype repo for team Bosque (name can potentially change). + +## Getting started + +```bash +docker compose up --build +``` + +or if already built + +```bash +docker compose up +``` + +## Getting latest database changes + +```bash +docker compose exec -it appserver yarn knex migrate:latest +``` + +## Installing new packages + +```bash +docker compose exec -it appserver yarn install +``` + + +## ENV Vars + +Auth0: Creating scafolding but not in use + +```bash +AUTH0_SECRET= +AUTH0_CLIENT_ID= +AUTH0_ISSUER_BASE_URL= +AUTH0_CLIENT_SECRET= +AUTH0_BASE_URL='http://localhost:3000' +``` + +Github: Not currently in use + +```bash +GITHUB_TOKEN= +``` + +OpenAI: Needed to for any AI interaciton + +```bash +OPENAI_API_KEY= +ASSISTANT_ID= +``` \ No newline at end of file diff --git a/appserver/.prettierignore b/appserver/.prettierignore new file mode 100644 index 0000000..536d88c --- /dev/null +++ b/appserver/.prettierignore @@ -0,0 +1 @@ +.next/ diff --git a/appserver/app/api/auth/[auth0]/route.ts b/appserver/app/api/auth/[auth0]/route.ts new file mode 100644 index 0000000..0635f78 --- /dev/null +++ b/appserver/app/api/auth/[auth0]/route.ts @@ -0,0 +1,84 @@ +// import { UserService } from "@/services/UserService"; + +import { + handleAuth, + handleCallback, + handleLogin, + Session, + updateSession, +} from "@auth0/nextjs-auth0"; +import { NextRequest } from "next/server"; + +// const afterCallback = async ( +// req: NextRequest, +// session: Session +// ): Promise => { +// if (!session) { +// throw new Error("Unable to authenticate user"); +// } + +// const { user } = session; +// const { sub, email_verified, email } = user; +// const storedUser = await UserService.getByAccountProviderId(sub); + +// if (storedUser) { +// const newSession = { +// ...session, +// user: { ...user, id: storedUser.id, username: storedUser.username }, +// }; + +// await updateSession(newSession); +// return newSession; +// //create user record +// } + +// const providerData = { +// id_token: session.idToken, +// refresh_token: session.refreshToken, +// access_token: session.accessToken, +// access_token_expires: session.accessTokenExpiresAt, +// provider: "auth0", +// provider_account_id: user.sub, +// }; + +// const newUser = await UserService.create( +// { +// email_verified, +// email, +// }, +// providerData +// ); + +// if (newUser) { +// const newSession = { +// ...session, +// user: { ...user, id: newUser.id, username: newUser.username }, +// }; +// await updateSession(newSession); +// return newSession; +// } +// throw new Error("Unable to authenticate user"); +// }; + +export const GET = handleAuth({ + login: handleLogin((req) => { + return { + returnTo: "/", + }; + }), + signup: handleLogin({ + authorizationParams: { + screen_hint: "signup", + }, + returnTo: "/profile", + }), + signIn: handleLogin({ + authorizationParams: { + screen_hint: "signin", + }, + returnTo: "/profile", + }), + callback: handleCallback((req) => { + return { redirectUri: "http://localhost:3000" }; + }), +}); diff --git a/appserver/app/api/chat/route.ts b/appserver/app/api/chat/route.ts new file mode 100644 index 0000000..2ec633e --- /dev/null +++ b/appserver/app/api/chat/route.ts @@ -0,0 +1,17 @@ +import { openai } from '@ai-sdk/openai' +import { convertToCoreMessages, streamText } from 'ai' + +// Allow streaming responses up to 30 seconds +export const maxDuration = 30 + +export async function POST(req: Request) { + const { messages } = await req.json() + + const result = await streamText({ + model: openai('gpt-4o'), + system: 'You are a helpful assistant.', + messages: convertToCoreMessages(messages), + }) + + return result.toDataStreamResponse() +} diff --git a/appserver/app/api/github/[actions]/routes.ts b/appserver/app/api/github/[actions]/routes.ts new file mode 100644 index 0000000..a32f350 --- /dev/null +++ b/appserver/app/api/github/[actions]/routes.ts @@ -0,0 +1,58 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import GithubService from '@Services/server/github.service' + +const githubService = new GithubService(process.env.GITHUB_TOKEN || '') + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { action, owner, repo, path } = req.query + + try { + let data + switch (action) { + case 'getRepo': + data = await githubService.getRepo(owner as string, repo as string) + break + case 'getContributors': + data = await githubService.getContributors( + owner as string, + repo as string + ) + break + case 'getIssues': + data = await githubService.getIssues(owner as string, repo as string) + break + case 'getPullRequests': + data = await githubService.getPullRequests( + owner as string, + repo as string + ) + break + case 'getBranches': + data = await githubService.getBranches(owner as string, repo as string) + break + case 'getTags': + data = await githubService.getTags(owner as string, repo as string) + break + case 'getContents': + data = await githubService.getContents( + owner as string, + repo as string, + path as string + ) + break + case 'getCommits': + data = await githubService.getCommits(owner as string, repo as string) + break + default: + return res.status(400).json({ message: 'Invalid action' }) + } + + res.status(200).json(data) + } catch (error) { + console.error(error) + res.status(500).json({ message: 'there was an error see logs' }) + } +} diff --git a/appserver/app/dashboard/code/page.module.scss b/appserver/app/dashboard/code/page.module.scss new file mode 100644 index 0000000..e4bf4cc --- /dev/null +++ b/appserver/app/dashboard/code/page.module.scss @@ -0,0 +1,7 @@ +@use 'styles/core/boilerplate' as *; + +.contributor { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-gap: 20px; +} diff --git a/appserver/app/dashboard/code/page.tsx b/appserver/app/dashboard/code/page.tsx new file mode 100644 index 0000000..323c185 --- /dev/null +++ b/appserver/app/dashboard/code/page.tsx @@ -0,0 +1,92 @@ +import GithubService from '@Services/server/github.service' +import styles from './page.module.scss' +import Markdown from 'react-markdown' + +export default async function Page() { + const githubService = new GithubService(process.env.GITHUB_TOKEN || '') + + const repo = await githubService.getRepo('Facebook', 'React') + const contributors = await githubService.getContributors('Facebook', 'React') + const issues = await githubService.getIssues('Facebook', 'React') + const content = await githubService.getContents( + 'Facebook', + 'React', + 'README.md' + ) + console.log('content', content) + console.log( + 'buffer content', + Buffer.from(content.content, 'base64').toString('utf-8') + ) + + return ( +
+

Code Base

+
+

File content test

+
+ + {Buffer.from(content.content, 'base64').toString('utf-8')} + +
+
+
+
+

Repo Details

+
+

+ Owner: {repo.owner.login} +

+

+ Private: {repo.private ? 'Yes' : 'No'} +

+

+ Default Branch: {repo.default_branch} +

+

+ Language: {repo.language} +

+

+ Size: {repo.size} +

+
+
+
+

Top Contributors

+
+ {contributors.map((contributor) => ( +
+

+ Login: {contributor.login} +

+

+ Contributions: {contributor.contributions} +

+
+ ))} +
+
+
+
+
+

Repo Issues

+
+ {issues.map((issue) => ( +
+

+ Title: {issue.title} +

+

+ State: {issue.state} +

+

+ Comments: {issue.comments} +

+
+ ))} +
+
+
+
+ ) +} diff --git a/appserver/app/dashboard/layout.module.scss b/appserver/app/dashboard/layout.module.scss new file mode 100644 index 0000000..26b6aa2 --- /dev/null +++ b/appserver/app/dashboard/layout.module.scss @@ -0,0 +1,6 @@ +@use 'styles/core/boilerplate' as *; + +.wrapper { + display: grid; + grid-template-columns: 300px 1fr; +} diff --git a/appserver/app/dashboard/layout.tsx b/appserver/app/dashboard/layout.tsx new file mode 100644 index 0000000..05d658c --- /dev/null +++ b/appserver/app/dashboard/layout.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from 'react' +import SideNav from '@Navigation/SideNav/SideNav' +import styles from './layout.module.scss' + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/appserver/app/dashboard/page.tsx b/appserver/app/dashboard/page.tsx new file mode 100644 index 0000000..b37c5f2 --- /dev/null +++ b/appserver/app/dashboard/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return
im the main page content
+} diff --git a/appserver/app/dashboard/sandbox/page.module.scss b/appserver/app/dashboard/sandbox/page.module.scss new file mode 100644 index 0000000..e4bf4cc --- /dev/null +++ b/appserver/app/dashboard/sandbox/page.module.scss @@ -0,0 +1,7 @@ +@use 'styles/core/boilerplate' as *; + +.contributor { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-gap: 20px; +} diff --git a/appserver/app/dashboard/sandbox/page.tsx b/appserver/app/dashboard/sandbox/page.tsx new file mode 100644 index 0000000..057c76d --- /dev/null +++ b/appserver/app/dashboard/sandbox/page.tsx @@ -0,0 +1,10 @@ +import ChatWrapper from '@Modules/chat/ChatWrapper/ChatWrapper' + +export default async function Page() { + return ( +
+

Sandbox Chat

+ +
+ ) +} diff --git a/appserver/app/layout.tsx b/appserver/app/layout.tsx new file mode 100644 index 0000000..b62b90f --- /dev/null +++ b/appserver/app/layout.tsx @@ -0,0 +1,44 @@ +import '../styles/globals.scss' +import 'react-element-forge/dist/style.css' +import { Inter, Space_Grotesk } from 'next/font/google' +import { UserProvider } from '@auth0/nextjs-auth0/client' +import { getSession } from '@auth0/nextjs-auth0' +import { ReactNode } from 'react' + +const inter = Inter({ + variable: '--inter-font', + subsets: ['latin'], + display: 'swap', + preload: true, +}) + +const space_grotesk = Space_Grotesk({ + variable: '--space_grotesk-font', + subsets: ['latin'], + display: 'swap', + preload: true, +}) + +export default async function RootLayout({ + children, +}: { + children: ReactNode +}) { + const session = await getSession() + const user = session?.user + + return ( + + + + + + + +
+ {children} + +
+ + ) +} diff --git a/appserver/app/page.tsx b/appserver/app/page.tsx new file mode 100644 index 0000000..cf569d5 --- /dev/null +++ b/appserver/app/page.tsx @@ -0,0 +1,12 @@ +import { getSession } from "@auth0/nextjs-auth0"; + +export default async function Page() { + const session = await getSession(); + const user = session?.user; + console.log("user", user); + return ( +
+ Login +
+ ); +} diff --git a/appserver/app/profile/layout.tsx b/appserver/app/profile/layout.tsx new file mode 100644 index 0000000..a14e64f --- /dev/null +++ b/appserver/app/profile/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/appserver/app/profile/page.tsx b/appserver/app/profile/page.tsx new file mode 100644 index 0000000..6433575 --- /dev/null +++ b/appserver/app/profile/page.tsx @@ -0,0 +1,22 @@ +import { getSession } from '@auth0/nextjs-auth0' +// import { useUser } from "@auth0/nextjs-auth0/client"; +import { redirect } from 'next/navigation' + +export default async function Page() { + const session = await getSession() + const user = session?.user + + if (!user) { + redirect('/') + } + console.log('user', user.sub) + + return user ? ( +
+

Hi {user.name}

+ Logout +
+ ) : ( +
No user found
+ ) +} diff --git a/appserver/hooks/useDarkMode.tsx b/appserver/hooks/useDarkMode.tsx new file mode 100644 index 0000000..e769522 --- /dev/null +++ b/appserver/hooks/useDarkMode.tsx @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +const useDarkMode = () => { + const [darkMode, setDarkMode] = useState(false); + + const eventHandler = (event: MediaQueryListEvent) => { + setDarkMode(event.matches); + }; + + useEffect(() => { + const darkmodeQuery = window.matchMedia('(prefers-color-scheme: dark)'); + setDarkMode(darkmodeQuery.matches); + darkmodeQuery.addEventListener('change', eventHandler); + + return () => { + darkmodeQuery.removeEventListener('change', eventHandler); + }; + }, []); + + return darkMode; +}; + +export default useDarkMode; diff --git a/appserver/hooks/useMediaQuery.tsx b/appserver/hooks/useMediaQuery.tsx new file mode 100644 index 0000000..6e6811f --- /dev/null +++ b/appserver/hooks/useMediaQuery.tsx @@ -0,0 +1,65 @@ +import { useState, useEffect } from 'react'; +const mobileBreak = 580; +const tabletBreak = 768; +const desktopBreak = 1024; + +/** + * React Hook Media Queries are used when you need to track window size outside of the SCSS ecosystem. + * Simply import hook and use its boolean response to track if a specific media query is being met. This can + * be used to add or remove a CSS class or render or remove a component in the JSX. + */ + +/** + * Base MediaQuery Hook + * + * Use this hook to create custom media query hooks. Please use pre-calibrated hooks below (ie 'useMobileDown' ) unless + * absolutely necessary to create your own hook. + * @param query + * @returns + */ +const useMediaQuery = (query: string) => { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const media = window.matchMedia(query); + if (media.matches !== matches) { + setMatches(media.matches); + } + const listener = () => setMatches(media.matches); + window.addEventListener('resize', listener); + return () => window.removeEventListener('resize', listener); + }, [matches, query]); + + return matches; +}; + +export default useMediaQuery; + +/** + * Mobile + */ +export const useMobileDown = () => { + return useMediaQuery(`(max-width: ${mobileBreak}px)`); +}; + +/** + * Tablet + */ +export const useTabletDown = () => { + return useMediaQuery(`(max-width: ${tabletBreak}px)`); +}; + +export const useTabletUp = () => { + return useMediaQuery(`(min-width: ${tabletBreak}px)`); +}; + +/** + * Desktop + */ +export const useDesktopDown = () => { + return useMediaQuery(`(max-width: ${desktopBreak}px)`); +}; + +export const usDesktopUp = () => { + return useMediaQuery(`(min-width: ${desktopBreak}px)`); +}; diff --git a/appserver/knex.ts b/appserver/knex.ts new file mode 100644 index 0000000..1d3de00 --- /dev/null +++ b/appserver/knex.ts @@ -0,0 +1,5 @@ +import config from './knexfile.js' +import Knex from 'knex' + +const _knex = Knex(config) +export default _knex diff --git a/appserver/knexfile.js b/appserver/knexfile.js index 094d198..e1f6e16 100644 --- a/appserver/knexfile.js +++ b/appserver/knexfile.js @@ -1,6 +1,9 @@ +const { pgvector } = require('pgvector/knex') + const config = { - client: "pg", + client: 'pg', connection: process.env.DATABASE_URL, -}; + ...pgvector, +} -module.exports = config; +module.exports = config diff --git a/appserver/migrations/20240726181241_create_users_table.js b/appserver/migrations/20240726181241_create_users_table.js new file mode 100644 index 0000000..02ecfc9 --- /dev/null +++ b/appserver/migrations/20240726181241_create_users_table.js @@ -0,0 +1,17 @@ +exports.up = function (knex) { + return knex.schema.createTable('users', function (table) { + table.string('id').primary() + table.string('given_name').notNullable() + table.string('nickname').notNullable() + table.string('name').notNullable() + table.string('picture').notNullable() + table.timestamp('updated_at').notNullable() + table.string('email').notNullable().unique() + table.boolean('email_verified').notNullable() + table.string('sid').notNullable() + }) +} + +exports.down = function (knex) { + return knex.schema.dropTable('users') +} diff --git a/appserver/migrations/20240726181242_artifact_embeddings_table.js b/appserver/migrations/20240726181242_artifact_embeddings_table.js new file mode 100644 index 0000000..1c6ed46 --- /dev/null +++ b/appserver/migrations/20240726181242_artifact_embeddings_table.js @@ -0,0 +1,15 @@ +const { pgvector } = require('pgvector/knex') + +exports.up = async function (knex) { + const resp = await knex.schema.enableExtension('vector') + return knex.schema.createTable('artifact_embeddings', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + table.specificType('embedding', 'vector(1536)') + table.string('name') + table.timestamps(true, true) + }) +} + +exports.down = async function (knex) { + return knex.schema.dropTable('artifact_embeddings') +} diff --git a/appserver/modules/chat/ChatMessage/ChatMessage.module.scss b/appserver/modules/chat/ChatMessage/ChatMessage.module.scss new file mode 100644 index 0000000..87b1f7d --- /dev/null +++ b/appserver/modules/chat/ChatMessage/ChatMessage.module.scss @@ -0,0 +1,57 @@ +@use 'styles/core/boilerplate' as *; + +.out_going_wrapper { + display: flex; + justify-content: flex-start; + margin-bottom: 12px; + color: $color-text-main; + + .message { + background-color: $color-background-chat-outgoing; + } +} + +.incomming_wrapper { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; + color: $color-text-reverse; + + .message { + background-color: $color-background-chat-incomming; + --copy-button-text-color: $color-text-reverse; + } +} + +.container { + max-width: 80%; + position: relative; +} + +.name_tag { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.message { + display: inline-block; + padding: 12px; + border-radius: 12px; + margin-bottom: 12px; + max-width: 100%; + overflow-x: auto; +} + +.actions { + display: flex; + position: absolute; + bottom: -40px; + right: 0px; +} + +.copy_button { + svg { + stroke: $color-text-reverse; + } +} diff --git a/appserver/modules/chat/ChatMessage/ChatMessage.tsx b/appserver/modules/chat/ChatMessage/ChatMessage.tsx new file mode 100644 index 0000000..966475a --- /dev/null +++ b/appserver/modules/chat/ChatMessage/ChatMessage.tsx @@ -0,0 +1,88 @@ +'use client' + +import styles from './ChatMessage.module.scss' +import { CopyButton } from 'react-element-forge' +import { MessageT } from 'types' +import Markdown from 'react-markdown' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { darcula } from 'react-syntax-highlighter/dist/esm/styles/prism' + +type MessageBubblePropsT = { + message: string + type: 'incomming' | 'out_going' + hasActions?: boolean + onInject?: Function +} +const MessageBubble = ({ + message, + type, + hasActions, + onInject, +}: MessageBubblePropsT) => { + console.log('rendering message bubble') + return ( +
+
+
+
+ + +
+ +
+ + ) : ( + + {children} + + ) + }, + }} + /> +
+
+ {hasActions && ( +
+ +
+ )} +
+
+ ) +} + +type ChatMessagePropsT = { + message: MessageT +} +const ChatMessage = ({ message }: ChatMessagePropsT) => { + return ( +
+ {message.role === 'assistant' && ( + + )} + {message.role === 'user' && ( + + )} +
+ ) +} + +export default ChatMessage diff --git a/appserver/modules/chat/ChatWrapper/ChatWrapper.module.scss b/appserver/modules/chat/ChatWrapper/ChatWrapper.module.scss new file mode 100644 index 0000000..dc40f99 --- /dev/null +++ b/appserver/modules/chat/ChatWrapper/ChatWrapper.module.scss @@ -0,0 +1,8 @@ +@use 'styles/core/boilerplate' as *; + +.messages_wrapper { + height: calc(100vh - 440px); + margin-bottom: 40px; + overflow-y: scroll; + padding: 12px; +} diff --git a/appserver/modules/chat/ChatWrapper/ChatWrapper.tsx b/appserver/modules/chat/ChatWrapper/ChatWrapper.tsx new file mode 100644 index 0000000..62eb1aa --- /dev/null +++ b/appserver/modules/chat/ChatWrapper/ChatWrapper.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useChat } from 'ai/react' +import ChatMessage from '../ChatMessage/ChatMessage' +import { useEffect, useCallback, useRef } from 'react' +import { Button, TextArea } from 'react-element-forge' +export type LlmT = 'openAi' | 'local' +import styles from './ChatWrapper.module.scss' + +const ChatWrapper = () => { + const { messages, input, handleInputChange, handleSubmit } = useChat({ + keepLastMessageOnError: true, + }) + + const messagesEndRef = useRef(null) + + const action = useCallback( + (event: KeyboardEvent) => { + const textarea = event.target as HTMLTextAreaElement + + // This just creates a new line when shift + enter is pressed, + if (event.shiftKey && event.key === 'Enter') { + event.preventDefault() + const start = textarea.selectionStart + const end = textarea.selectionEnd + + textarea.value = + textarea.value.substring(0, start) + + '\n' + + textarea.value.substring(end) + + textarea.selectionStart = textarea.selectionEnd = start + 1 + return + } + + if (event.key === 'Enter') { + event.preventDefault() + handleSubmit() + } + }, + [handleSubmit] + ) + + useEffect(() => { + scrollToBottom() + }, [messages]) + + useEffect(() => { + const chatBox = document.getElementById('chat-box') + chatBox?.addEventListener('keydown', action) + + return () => { + chatBox?.removeEventListener('keydown', action) + } + }, [action]) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + return ( + <> +
+ {messages.map((message) => ( + + ))} +
+
+ +
+