├── public ├── .gitignore ├── favicon.ico ├── og-image.png ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-150x150.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-256x256.png ├── android-chrome-512x512.png ├── site.webmanifest ├── browserconfig.xml ├── safari-pinned-tab.svg └── image │ ├── logo-dark-square-transparent.svg │ ├── logo-light-square-transparent.svg │ ├── logo-dark-square.svg │ └── logo-light-square.svg ├── .npmrc ├── composables ├── format-message.ts ├── session.ts ├── trpc.ts ├── preset.ts ├── ui.ts ├── settings.ts ├── device.ts ├── search.ts ├── dom-scroll-lock.ts ├── teleport.ts ├── appearence.ts ├── persona.ts ├── knowledge.ts ├── idb.ts ├── setup.ts ├── shiki.ts ├── deta.ts └── language-model.ts ├── assets └── logo.png ├── docs └── screenshot.png ├── pages ├── settings │ ├── index.vue │ ├── model.vue │ ├── deta.vue │ ├── api-key.vue │ └── appearance.vue ├── index.vue ├── chat │ ├── index.vue │ └── share │ │ └── [conversationId].vue ├── personas.vue ├── sk.vue ├── password.vue ├── history.vue ├── knowledge.vue └── settings.vue ├── index.d.ts ├── tsconfig.json ├── utils ├── log.ts ├── app-provide.ts ├── string.ts ├── map-value.ts ├── local-storage-ref.ts ├── handle.ts ├── object.ts ├── types.ts └── fetch-sse.ts ├── components ├── markdown │ ├── unordered-list.vue │ ├── code-inline.vue │ ├── paragraph.vue │ ├── heading.vue │ ├── renderer.vue │ └── code-block.vue ├── search-bar.vue ├── go │ ├── link.vue │ ├── splash.vue │ ├── typography.vue │ ├── input.vue │ ├── button-select.vue │ ├── button.vue │ └── long-press-button.vue ├── app-chat │ ├── history-empty.vue │ ├── history-typing.vue │ ├── index.vue │ ├── scroll-to-bottom-button.vue │ ├── history.vue │ ├── settings.vue │ ├── prompt.vue │ ├── settings-value.vue │ ├── history-container.vue │ └── header.vue ├── logo.vue ├── animated-text.vue ├── skeleton.vue ├── app-splash-screen.vue ├── color-mode-toggle.vue ├── sidebar │ ├── item.vue │ └── api-key-alert.vue ├── settings │ ├── model │ │ ├── select.vue │ │ └── options.vue │ ├── max-tokens │ │ └── input.vue │ └── creativity │ │ └── select.vue ├── app-messages-sidebar │ ├── favorite-conversations.vue │ ├── recent-conversations.vue │ └── index.vue ├── app-navbar-mobile.vue ├── dialog.vue ├── knowledge │ └── manager.vue ├── app-navbar │ └── index.vue └── app-prompt-input.vue ├── .gitignore ├── plugins ├── auto-animate.client.ts ├── floating-vue.ts ├── environment-variable.ts └── deta.ts ├── server ├── api │ ├── auth │ │ └── login.post.ts │ ├── trpc │ │ └── [trpc].ts │ └── knowledge.post.ts ├── markdown │ └── index.ts ├── trpc │ ├── trpc.ts │ ├── routers │ │ ├── deta │ │ │ ├── index.ts │ │ │ ├── preferences.ts │ │ │ ├── conversation.ts │ │ │ └── message.ts │ │ ├── auth.ts │ │ ├── model.ts │ │ └── index.ts │ └── context.ts ├── db │ └── deta.ts ├── embedding │ └── index.ts └── from │ └── scraping.ts ├── .eslintrc ├── middleware ├── redirect.global.ts └── password.global.ts ├── Spacefile ├── scripts └── prepare.ts ├── layouts ├── blank.vue └── default.vue ├── Dockerfile ├── Discovery.md ├── LICENSE ├── README.md ├── unocss.config.ts ├── package.json ├── app.vue ├── .github └── workflows │ ├── release-preview.yml │ ├── release-staging.yml │ └── release-production.yml └── nuxt.config.ts /public/.gitignore: -------------------------------------------------------------------------------- 1 | shiki -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /composables/format-message.ts: -------------------------------------------------------------------------------- 1 | export const useFormatMessage = () => {} 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/assets/logo.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/og-image.png -------------------------------------------------------------------------------- /pages/settings/index.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Set to true if the current environment is development. 3 | */ 4 | const __DEV__: boolean -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henrycunh/golem/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /utils/log.ts: -------------------------------------------------------------------------------- 1 | import consola from 'consola' 2 | 3 | export const logger = consola.create({ 4 | level: __DEV__ ? 4 : 3, 5 | }) 6 | -------------------------------------------------------------------------------- /components/markdown/unordered-list.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | 10 | .space 11 | .vercel 12 | .DS_Store -------------------------------------------------------------------------------- /utils/app-provide.ts: -------------------------------------------------------------------------------- 1 | export function appProvide(key: string, provided: any) { 2 | const { vueApp } = useNuxtApp() 3 | vueApp.provide(key, provided) 4 | } 5 | -------------------------------------------------------------------------------- /plugins/auto-animate.client.ts: -------------------------------------------------------------------------------- 1 | import { autoAnimatePlugin } from '@formkit/auto-animate/vue' 2 | 3 | export default defineNuxtPlugin((nuxt) => { 4 | nuxt.vueApp.use(autoAnimatePlugin) 5 | }) 6 | -------------------------------------------------------------------------------- /utils/string.ts: -------------------------------------------------------------------------------- 1 | export default function trimIndent(content: string) { 2 | const lines = content.split('\n') 3 | const indent = lines.map(line => line.trimStart()) 4 | return indent.join('\n') 5 | } 6 | -------------------------------------------------------------------------------- /server/api/auth/login.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const { accessToken } = await readBody(event) 3 | setCookie(event, 'ungpt-session', accessToken, {}) 4 | 5 | return accessToken 6 | }) 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@antfu", 3 | "rules": { 4 | "vue/html-indent": ["error", 4], 5 | "@typescript-eslint/indent": ["error", 4], 6 | "no-console": "off", 7 | "curly": ["error", "all"] 8 | } 9 | } -------------------------------------------------------------------------------- /components/markdown/code-inline.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /plugins/floating-vue.ts: -------------------------------------------------------------------------------- 1 | import FloatingVue from 'floating-vue' 2 | import { defineNuxtPlugin } from '#imports' 3 | import 'floating-vue/dist/style.css' 4 | 5 | export default defineNuxtPlugin(({ vueApp }) => { 6 | vueApp.use(FloatingVue) 7 | }) 8 | -------------------------------------------------------------------------------- /utils/map-value.ts: -------------------------------------------------------------------------------- 1 | export default function mapValue(map: Record, value: string, defaultValue?: any) { 2 | if (value in map) { 3 | return map[value] 4 | } 5 | else { 6 | return defaultValue 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /components/markdown/paragraph.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 13 | -------------------------------------------------------------------------------- /components/search-bar.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNuxtApiHandler } from 'trpc-nuxt' 2 | import { appRouter } from '~/server/trpc/routers' 3 | import { createContext } from '~/server/trpc/context' 4 | 5 | // export API handler 6 | export default createNuxtApiHandler({ 7 | router: appRouter, 8 | createContext, 9 | }) 10 | -------------------------------------------------------------------------------- /server/markdown/index.ts: -------------------------------------------------------------------------------- 1 | export function splitMarkdownByHeadingLevel(content: string, level: number) { 2 | const headingRegex = new RegExp(`^#{${level}} `, 'gm') 3 | const sections = content 4 | .split(headingRegex) 5 | .filter(Boolean) 6 | .map(section => section.trim()) 7 | return sections 8 | } 9 | -------------------------------------------------------------------------------- /middleware/redirect.global.ts: -------------------------------------------------------------------------------- 1 | const redirectMapping = { 2 | '/': '/chat', 3 | '/settings': '/settings/api-key', 4 | } as Record 5 | 6 | export default defineNuxtRouteMiddleware((to) => { 7 | if (to.path in redirectMapping) { 8 | return navigateTo(redirectMapping[to.path]) 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /server/trpc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server' 2 | import type { Context } from '~/server/trpc/context' 3 | 4 | const t = initTRPC.context().create() 5 | /** 6 | * Unprotected procedure 7 | **/ 8 | export const publicProcedure = t.procedure 9 | export const router = t.router 10 | export const middleware = t.middleware 11 | -------------------------------------------------------------------------------- /utils/local-storage-ref.ts: -------------------------------------------------------------------------------- 1 | import type { RemovableRef } from '@vueuse/core' 2 | 3 | export function syncStorageRef(storageRef: RemovableRef) { 4 | const newRef = ref(storageRef.value) 5 | watch(newRef, (newValue) => { 6 | storageRef.value = newValue 7 | }, { immediate: true }) 8 | 9 | return newRef 10 | } 11 | -------------------------------------------------------------------------------- /composables/session.ts: -------------------------------------------------------------------------------- 1 | export function useSession() { 2 | const route = useRoute() 3 | const isLoggedIn = useState('is-logged-in', () => false) 4 | 5 | const isOnSharePage = computed(() => { 6 | return route.name === 'chat-share-conversationId' 7 | }) 8 | 9 | return { 10 | isOnSharePage, 11 | isLoggedIn, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/trpc/routers/deta/index.ts: -------------------------------------------------------------------------------- 1 | import { publicProcedure, router } from '~/server/trpc/trpc' 2 | 3 | export const detaInfoRouter = router({ 4 | 5 | isEnabled: publicProcedure 6 | .query(async () => { 7 | return Boolean(process?.env?.DETA_PROJECT_KEY) 8 | }), 9 | 10 | }) 11 | 12 | export type DetaInfoRouter = typeof detaInfoRouter 13 | -------------------------------------------------------------------------------- /components/go/link.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /pages/chat/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /server/db/deta.ts: -------------------------------------------------------------------------------- 1 | import { Deta } from 'deta' 2 | import type Base from 'deta/dist/types/base' 3 | 4 | export function getDetaBase(collection: string) { 5 | const detaKey = useRuntimeConfig().detaKey || process.env?.DETA_PROJECT_KEY 6 | if (!detaKey) { 7 | return {} as Base 8 | } 9 | const deta = Deta(detaKey) 10 | const db = deta.Base(collection) 11 | return db 12 | } 13 | -------------------------------------------------------------------------------- /composables/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCNuxtClient, httpBatchLink } from 'trpc-nuxt/client' 2 | import type { AppRouter } from '~~/server/trpc/routers' 3 | 4 | export function useClient() { 5 | const client = createTRPCNuxtClient({ 6 | links: [ 7 | httpBatchLink({ 8 | url: '/api/trpc', 9 | }), 10 | ], 11 | }) 12 | 13 | return client 14 | } 15 | -------------------------------------------------------------------------------- /server/trpc/routers/auth.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { publicProcedure, router } from '~/server/trpc/trpc' 3 | 4 | export const authRouter = router({ 5 | login: publicProcedure 6 | .input(z.string()) 7 | .mutation(async ({ input }) => { 8 | const { password } = useRuntimeConfig() 9 | return input === password 10 | }), 11 | }) 12 | 13 | export type AuthRouter = typeof authRouter 14 | -------------------------------------------------------------------------------- /Spacefile: -------------------------------------------------------------------------------- 1 | # Spacefile Docs: https://go.deta.dev/docs/spacefile/v0 2 | v: 0 3 | icon: assets/logo.png 4 | micros: 5 | - name: golem 6 | src: . 7 | engine: nuxt 8 | primary: true 9 | public_routes: 10 | - /chat/share/* 11 | - /_nuxt/* 12 | - /api/trpc/deta* 13 | - /shiki/* 14 | - /image/* 15 | - /*.png 16 | - /*.ico 17 | - /sw.js 18 | - /manifest.json -------------------------------------------------------------------------------- /components/app-chat/history-empty.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /utils/handle.ts: -------------------------------------------------------------------------------- 1 | type HandleReturn = Promise<{ error: null; data: T } | { error: Error; data: null }> 2 | export default async function handle(promise: (Promise | (() => Promise))): HandleReturn { 3 | try { 4 | if (typeof promise === 'function') { 5 | promise = promise() 6 | } 7 | const data = await promise 8 | return { error: null, data } 9 | } 10 | catch (error: any) { 11 | return { error, data: null } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /composables/preset.ts: -------------------------------------------------------------------------------- 1 | export const usePreset = () => { 2 | const currentPreset = useState<{ 3 | title: string 4 | content: string 5 | } | null>(() => null) 6 | 7 | const setPreset = (preset: { title: string; content: string }) => { 8 | currentPreset.value = preset 9 | } 10 | 11 | const clearPreset = () => { 12 | currentPreset.value = null 13 | } 14 | 15 | return { 16 | currentPreset, 17 | setPreset, 18 | clearPreset, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/trpc/context.ts: -------------------------------------------------------------------------------- 1 | import type { inferAsyncReturnType } from '@trpc/server' 2 | import { getDetaBase } from '../db/deta' 3 | /** 4 | * Creates context for an incoming request 5 | * @link https://trpc.io/docs/context 6 | */ 7 | export const createContext = () => ({ 8 | deta: { 9 | conversations: getDetaBase('conversations'), 10 | messages: getDetaBase('messages'), 11 | preferences: getDetaBase('preferences'), 12 | }, 13 | }) 14 | export type Context = inferAsyncReturnType 15 | -------------------------------------------------------------------------------- /server/trpc/routers/model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { encoding_for_model } from '@dqbd/tiktoken' 3 | import { publicProcedure, router } from '~/server/trpc/trpc' 4 | 5 | export const modelRouter = router({ 6 | getTokenCount: publicProcedure 7 | .input(z.string()) 8 | .mutation(async ({ input }) => { 9 | const encoding = encoding_for_model('gpt-3.5-turbo') 10 | return encoding.encode(input).length 11 | }), 12 | }) 13 | 14 | export type ModelRouter = typeof modelRouter 15 | -------------------------------------------------------------------------------- /composables/ui.ts: -------------------------------------------------------------------------------- 1 | export function useUI() { 2 | const { isSmallDesktop } = useDevice() 3 | 4 | const scrollToBottom = inject('scrollToBottom') as () => void 5 | const getScrollHeight = inject('getScrollHeight') as () => number 6 | const chatScrolledHeight = inject('chatScrolledHeight') as Ref 7 | const isSidebarCompact = computed(() => isSmallDesktop.value) 8 | 9 | return { 10 | scrollToBottom, 11 | getScrollHeight, 12 | chatScrolledHeight, 13 | isSidebarCompact, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /components/app-chat/history-typing.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /scripts/prepare.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | 3 | const dereference = process.platform === 'win32' ? true : undefined 4 | 5 | await fs.copy('node_modules/shiki-es/dist/assets', 'public/shiki/', { 6 | dereference, 7 | filter: (src: any) => src === 'node_modules/shiki/' || src.includes('languages') || src.includes('dist'), 8 | }) 9 | await fs.copy('node_modules/theme-vitesse/themes', 'public/shiki/themes', { dereference }) 10 | await fs.copy('node_modules/theme-vitesse/themes', 'node_modules/shiki/themes', { overwrite: true, dereference }) 11 | -------------------------------------------------------------------------------- /components/logo.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 17 | -------------------------------------------------------------------------------- /layouts/blank.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /components/markdown/heading.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /components/animated-text.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 13 | 25 | -------------------------------------------------------------------------------- /middleware/password.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((from) => { 2 | const { isPasswordRequired } = useSettings() 3 | const { isLoggedIn } = useSession() 4 | if ( 5 | from.path !== '/password' 6 | && ( 7 | isPasswordRequired.value 8 | && !isLoggedIn.value 9 | ) 10 | ) { 11 | return navigateTo('/password') 12 | } 13 | else if ( 14 | from.path === '/password' 15 | && ( 16 | !isPasswordRequired.value 17 | || isLoggedIn.value 18 | ) 19 | ) { 20 | return navigateTo('/') 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /components/app-chat/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /composables/settings.ts: -------------------------------------------------------------------------------- 1 | import { useStorage } from '@vueuse/core' 2 | 3 | export function useSettings() { 4 | const maxTokens = useStorage('golem-model-max-tokens', '1024') 5 | const modelUsed = useStorage('golem-model', 'gpt-3.5-turbo') 6 | const instanceApiKey = useState('golem-instance-api-key', () => null) 7 | const apiKey = useStorage('golem-api-key', null) 8 | const isPasswordRequired = useState('golem-is-password-required', () => false) 9 | 10 | return { 11 | maxTokens, 12 | modelUsed, 13 | apiKey, 14 | instanceApiKey, 15 | isPasswordRequired, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components/skeleton.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 31 | -------------------------------------------------------------------------------- /composables/device.ts: -------------------------------------------------------------------------------- 1 | import { breakpointsTailwind } from '@vueuse/core' 2 | export function useDevice() { 3 | const breakpoint = useBreakpoints(breakpointsTailwind) 4 | 5 | const isSmallDesktop = breakpoint.smallerOrEqual('lg') 6 | const isMobile = breakpoint.smallerOrEqual('sm') 7 | 8 | const isMobileSafari = (() => { 9 | const headers = ( 10 | process.server 11 | ? useRequestHeaders()['user-agent'] 12 | : navigator.userAgent 13 | ) || '' 14 | return headers.includes('iPhone') || headers.includes('iPad') 15 | })() 16 | 17 | return { 18 | isMobile, 19 | isSmallDesktop, 20 | isMobileSafari, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/api/knowledge.post.ts: -------------------------------------------------------------------------------- 1 | import { indexDocuments } from '../embedding' 2 | import { scrapeUrl } from '../from/scraping' 3 | 4 | export default defineEventHandler(async (event) => { 5 | const payload = await readBody(event) 6 | 7 | if (payload.type === 'url') { 8 | const { url, embeddings } = payload 9 | 10 | if (!url) { 11 | throw new Error('No URL provided') 12 | } 13 | 14 | const { markdown, favicon, title } = await scrapeUrl(url) 15 | if (embeddings) { 16 | return await indexDocuments([{ 17 | url, 18 | content: markdown, 19 | }]) 20 | } 21 | return { markdown, favicon, title } 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /server/trpc/routers/index.ts: -------------------------------------------------------------------------------- 1 | import { router } from '../trpc' 2 | import { authRouter } from './auth' 3 | import { detaInfoRouter } from './deta' 4 | import { conversationRouter } from './deta/conversation' 5 | import { messageRouter } from './deta/message' 6 | import { preferencesRouter } from './deta/preferences' 7 | import { modelRouter } from './model' 8 | export const appRouter = router({ 9 | deta: router({ 10 | conversations: conversationRouter, 11 | messages: messageRouter, 12 | info: detaInfoRouter, 13 | preferences: preferencesRouter, 14 | }), 15 | model: modelRouter, 16 | auth: authRouter, 17 | }) 18 | // export type definition of API 19 | export type AppRouter = typeof appRouter 20 | -------------------------------------------------------------------------------- /composables/search.ts: -------------------------------------------------------------------------------- 1 | import Fuse from 'fuse.js' 2 | import type { types } from '~~/utils/types' 3 | export function useSearch() { 4 | const searchTerm = useState('search-term', () => '') 5 | 6 | const filterConversationsBySearchTerm = (conversations: types.Conversation[]) => { 7 | if (!searchTerm.value) { 8 | return conversations 9 | } 10 | const fuse = new Fuse(conversations, { 11 | keys: ['title', 'messages.text'], 12 | threshold: 0.3, 13 | }) 14 | const results = fuse.search(searchTerm.value) 15 | return results.map(result => result.item) 16 | } 17 | 18 | return { 19 | searchTerm, 20 | filterConversationsBySearchTerm, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /components/go/splash.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | -------------------------------------------------------------------------------- /components/app-splash-screen.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | -------------------------------------------------------------------------------- /plugins/environment-variable.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(() => { 2 | if (process.server) { 3 | const { apiKey: apiKeyFromEnv, password } = useRuntimeConfig() 4 | const { isPasswordRequired, instanceApiKey } = useSettings() 5 | const { isLoggedIn } = useSession() 6 | if (apiKeyFromEnv) { 7 | instanceApiKey.value = apiKeyFromEnv 8 | } 9 | if (password) { 10 | isPasswordRequired.value = true 11 | isLoggedIn.value = String(useCookie('golem-password').value) === String(password) 12 | } 13 | } 14 | else { 15 | const { apiKey, instanceApiKey } = useSettings() 16 | if (instanceApiKey.value) { 17 | apiKey.value = instanceApiKey.value 18 | } 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /components/color-mode-toggle.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a builder stage to install and build dependencies 2 | FROM node:16-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Install pnpm globally and copy package files 7 | COPY pnpm-lock.yaml package.json ./ 8 | COPY scripts/prepare.ts ./scripts/prepare.ts 9 | RUN yarn global add pnpm && pnpm install 10 | 11 | # Copy app source code and build for production 12 | COPY . . 13 | RUN pnpm run build 14 | RUN ls 15 | 16 | # Use a second stage to create a smaller image without build dependencies 17 | FROM node:16-alpine 18 | 19 | WORKDIR /app 20 | 21 | # Copy built app from previous stage 22 | COPY --from=builder /app/.output /app 23 | 24 | # Expose the port and switch to non-root user 25 | EXPOSE 3000 26 | USER node 27 | 28 | # Start the app by running the server entrypoint 29 | CMD ["node", "./server/index.mjs"] -------------------------------------------------------------------------------- /components/sidebar/item.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 28 | -------------------------------------------------------------------------------- /components/settings/model/select.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 25 | -------------------------------------------------------------------------------- /components/settings/max-tokens/input.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 25 | -------------------------------------------------------------------------------- /Discovery.md: -------------------------------------------------------------------------------- 1 | --- 2 | app_name: Golem 3 | tagline: An open-source ChatGPT UI alternative 4 | theme_color: "#f7f7f7" 5 | git: "https://github.com/henrycunh/golem" 6 | --- 7 | 8 | 9 |
10 | 11 | 12 | 13 |
14 | 15 | ![](./docs/screenshot.png) 16 | 17 | ## 🚀 Key Features: 18 | 19 | - ✅ Access to GPT-3.5 / GPT-4 APIs 20 | - 🎨 Customizable UI 21 | - ⭐️ Favorite messages and conversations 22 | - 🔎 Search messages 23 | - 🗑️ Delete message 24 | 25 | ## ☁ Hosted on the Personal Cloud with Deta 26 | 27 | - 🔑 Your data and conversations only belong to you 28 | - 📦  Integrate easily with other apps, everything on the personal cloud is programmable 29 | - 🍻 Share chat history 30 | 31 | ## 🗺️ Roadmap: 32 | 33 | - 📄 External knowledge (documents, websites, etc.) support 34 | - 🤖 Additional AI language models support 35 | - ☁️ User accounts and cross device synchronisation 36 | - 🌍 Multi-language support 37 | - 🔌 Plugins support -------------------------------------------------------------------------------- /server/trpc/routers/deta/preferences.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { publicProcedure, router } from '~/server/trpc/trpc' 3 | 4 | const AvailablePreferences = [ 5 | 'api-key', 6 | 'color', 7 | ] as const 8 | 9 | export const preferencesRouter = router({ 10 | 11 | get: publicProcedure 12 | .input( 13 | z.enum(AvailablePreferences), 14 | ) 15 | .query(async ({ ctx, input }) => { 16 | return (await ctx.deta.preferences.get(input))?.value 17 | }), 18 | 19 | set: publicProcedure 20 | .input( 21 | z.object({ 22 | key: z.enum(AvailablePreferences), 23 | value: z.string(), 24 | }), 25 | ) 26 | .mutation(async ({ ctx, input }) => { 27 | await ctx.deta.preferences.put({ 28 | key: input.key, 29 | value: input.value, 30 | }) 31 | }), 32 | }) 33 | 34 | export type PreferencesRouter = typeof preferencesRouter 35 | -------------------------------------------------------------------------------- /pages/personas.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 35 | -------------------------------------------------------------------------------- /plugins/deta.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(async () => { 2 | if (process.client) { 3 | const { isDetaEnabled } = useDeta() 4 | const client = useClient() 5 | isDetaEnabled.value = await client.deta.info.isEnabled.query() 6 | 7 | if (isDetaEnabled.value) { 8 | const { instanceApiKey, apiKey } = useSettings() 9 | const detaApiKey = await client.deta.preferences.get.query('api-key') as string 10 | if (detaApiKey) { 11 | instanceApiKey.value = detaApiKey 12 | apiKey.value = detaApiKey 13 | } 14 | 15 | const { setPalette } = useAppearance() 16 | const color = await client.deta.preferences.get.query('color') as string 17 | if (color) { 18 | setPalette(color) 19 | } 20 | } 21 | } 22 | else { 23 | const { isDetaEnabled } = useDeta() 24 | const { detaKey } = useRuntimeConfig() 25 | isDetaEnabled.value = Boolean(detaKey) 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /pages/sk.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 39 | -------------------------------------------------------------------------------- /composables/dom-scroll-lock.ts: -------------------------------------------------------------------------------- 1 | import { useCssVar } from '@vueuse/core' 2 | import type { Ref } from 'vue' 3 | import { ref, watch } from 'vue' 4 | 5 | export const useDOMScrollLock = (modelValue: Ref) => { 6 | const refRoot = ref() 7 | 8 | const scrollbarWidth = useCssVar('--scrollbar-width', refRoot) 9 | const windowScrollTop = useCssVar('--window-scroll-top', refRoot) 10 | 11 | watch(modelValue, (val) => { 12 | if (!scrollbarWidth.value) { 13 | // Thanks: https://stackoverflow.com/a/56283274/10796681 14 | scrollbarWidth.value = `${window.innerWidth - document.body.clientWidth}px` 15 | } 16 | 17 | const classes = document.documentElement.classList 18 | 19 | if (val) { 20 | windowScrollTop.value = `-${window.scrollY}px` 21 | classes.add('scroll-lock') 22 | } 23 | else { 24 | const scrollY = windowScrollTop.value 25 | classes.remove('scroll-lock') 26 | window.scrollTo(0, parseInt(scrollY || '0') * -1) 27 | } 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /components/app-chat/scroll-to-bottom-button.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 31 | -------------------------------------------------------------------------------- /components/go/typography.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 henrycunh 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 | -------------------------------------------------------------------------------- /components/settings/model/options.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 40 | -------------------------------------------------------------------------------- /composables/teleport.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | import { computed, unref } from 'vue' 3 | 4 | export function useTeleport(target?: Ref) { 5 | const teleportTarget = computed(() => { 6 | const _target = unref(target) 7 | 8 | if (typeof window === 'undefined') { 9 | return undefined 10 | } 11 | 12 | const targetElement = _target === undefined 13 | ? document.body 14 | : typeof _target === 'string' 15 | ? document.querySelector(_target) 16 | : _target 17 | 18 | if (targetElement == null) { 19 | console.warn(`Unable to locate target ${_target}`) 20 | 21 | return undefined 22 | } 23 | 24 | if (!useTeleport.cache.has(targetElement)) { 25 | const el = document.createElement('div') 26 | el.id = 'a-teleport-target' 27 | targetElement.appendChild(el) 28 | useTeleport.cache.set(targetElement, el) 29 | } 30 | 31 | return useTeleport.cache.get(targetElement) 32 | }) 33 | 34 | return { teleportTarget } 35 | } 36 | useTeleport.cache = new WeakMap() 37 | -------------------------------------------------------------------------------- /components/settings/creativity/select.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | -------------------------------------------------------------------------------- /components/go/input.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | 39 | -------------------------------------------------------------------------------- /composables/appearence.ts: -------------------------------------------------------------------------------- 1 | import { tailwindcssPaletteGenerator } from '@bobthered/tailwindcss-palette-generator' 2 | import { useStorage } from '@vueuse/core' 3 | 4 | export function useAppearance() { 5 | const color = useStorage('golem-base-color', '#a633cc') 6 | const navigationBarPosition = useStorage('golem-navbar-position', 'left') 7 | 8 | function setPalette(newColor?: string) { 9 | if (newColor) { 10 | color.value = newColor 11 | } 12 | const palette = tailwindcssPaletteGenerator(color.value) 13 | for (const [shade, color] of Object.entries(palette.primary)) { 14 | const hexToRgb = (hex: string) => { 15 | hex = hex.replace('#', '') 16 | const r = parseInt(hex.substring(0, 2), 16) 17 | const g = parseInt(hex.substring(2, 4), 16) 18 | const b = parseInt(hex.substring(4, 6), 16) 19 | return `${r}, ${g}, ${b}` 20 | } 21 | document.documentElement.style.setProperty(`--color-primary-${shade}`, hexToRgb(color)) 22 | } 23 | } 24 | 25 | return { 26 | color, 27 | setPalette, 28 | navigationBarPosition, 29 | } 30 | } 31 | 32 | export type NavigationBarPosition = 'top' | 'bottom' | 'left' | 'right' 33 | -------------------------------------------------------------------------------- /pages/password.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 49 | -------------------------------------------------------------------------------- /components/go/button-select.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 40 | -------------------------------------------------------------------------------- /components/app-messages-sidebar/favorite-conversations.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 40 | -------------------------------------------------------------------------------- /components/app-chat/history.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 46 | -------------------------------------------------------------------------------- /pages/settings/model.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 46 | -------------------------------------------------------------------------------- /components/app-chat/settings.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 49 | -------------------------------------------------------------------------------- /pages/settings/deta.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 48 | -------------------------------------------------------------------------------- /utils/object.ts: -------------------------------------------------------------------------------- 1 | export function omitKeys, K extends keyof T>(obj: T, keys: K[]): Omit { 2 | const result = {} as Omit 3 | for (const [key, value] of Object.entries(obj)) { 4 | if (!keys.includes(key as unknown as K)) { 5 | result[key as Exclude] = value as T[Exclude] 6 | } 7 | } 8 | return result 9 | } 10 | 11 | // Makes any non-object, non-array or non-primitive a string (e.g. Date) 12 | export function pruneObject>(obj: T): T { 13 | const result = {} as T 14 | for (const [key, value] of Object.entries(obj)) { 15 | if (typeof value === 'object' && value !== null) { 16 | result[key as keyof T] = pruneObject(value as unknown as Record) as T[keyof T] 17 | } 18 | else { 19 | result[key as keyof T] = value as T[keyof T] 20 | } 21 | } 22 | return result 23 | } 24 | 25 | export function parseDateFields>(obj: T, fields: U): 26 | { 27 | [K in keyof T]: K extends Extract ? Date : T[K] 28 | } { 29 | const result = {} as any 30 | for (const [key, value] of Object.entries(obj)) { 31 | if (fields.includes(key as unknown as U[number])) { 32 | result[key as U[number]] = new Date(value as unknown as string) 33 | } 34 | else { 35 | result[key as U[number]] = value as T[keyof T] 36 | } 37 | } 38 | return result as { 39 | [K in keyof T]: K extends Extract ? Date : T[K] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /components/app-navbar-mobile.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 62 | -------------------------------------------------------------------------------- /components/sidebar/api-key-alert.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 56 | -------------------------------------------------------------------------------- /components/app-messages-sidebar/recent-conversations.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 53 | -------------------------------------------------------------------------------- /composables/persona.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import type { types } from '~~/utils/types' 3 | 4 | export function usePersona() { 5 | const db = useIDB() 6 | 7 | const personaList = useState('personaList', () => []) 8 | 9 | async function initPersonaList() { 10 | personaList.value = await db.table('personas').toArray() 11 | if (!personaList.value.length) { 12 | await createPersona({ 13 | id: nanoid(), 14 | title: 'Golem', 15 | instructions: 'You are Golem, a large language model based assistant. Answer as concisely as possible.', 16 | }) 17 | } 18 | } 19 | 20 | async function createPersona(persona: types.Persona) { 21 | const newPersona = { 22 | ...persona, 23 | id: nanoid(), 24 | } 25 | await db.table('personas').add(newPersona) 26 | personaList.value.push(newPersona) 27 | await updatePersonaList() 28 | } 29 | 30 | async function deletePersona(personaId: string) { 31 | await db.table('personas').delete(personaId) 32 | personaList.value = personaList.value.filter(p => p.id !== personaId) 33 | await updatePersonaList() 34 | } 35 | 36 | async function updatePersona(personaId: string, update: Partial) { 37 | const persona = personaList.value.find(p => p.id === personaId) 38 | if (persona) { 39 | await db.table('personas').put({ ...persona, ...update }) 40 | } 41 | await updatePersonaList() 42 | } 43 | 44 | async function updatePersonaList() { 45 | personaList.value = await db.table('personas').toArray() 46 | } 47 | 48 | return { 49 | initPersonaList, 50 | personaList, 51 | createPersona, 52 | deletePersona, 53 | updatePersona, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /components/app-chat/prompt.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |

Golem is an open-source conversational UI and alternative to ChatGPT

7 | 8 | 9 | 10 | 11 |

12 | 13 | 14 | 15 | ## 🚀 Key Features: 16 | 17 | - ✅ Access to GPT-3.5 / GPT-4 APIs 18 | - 🎨 Customizable UI 19 | - 🌑 Dark mode 20 | - 🗑️ Delete messages 21 | - ⭐️ Favorite messages and conversations 22 | - 🔎 Search messages 23 | - ⚙️ Custom settings for conversations 24 | 25 | ## ☁ Host on the Personal Cloud with Deta 26 | 27 | - 🔑 Your data and conversations only belong to you 28 | - 📦 Integrate easily with other apps, everything on the personal cloud is programmable 29 | - 💠 Cross device synchronisation 30 | - 🍻 Share chat history 31 | 32 |
33 | 34 | 35 | 36 |
37 | 38 | ## 🛠 Configuration 39 | You can use **environment variables** to customize your instance. 40 | 41 | | Variable | Description | 42 | | -------- | ----------- | 43 | | **`GOLEM_PASSWORD`** | Protects the instance with this password, which will be prompted at every usage. | 44 | | **`OPENAI_API_KEY`** | Enforces the usage of this API Key on the instance. | 45 | 46 | ## 🐳 Running on Docker 47 | You can run Golem on Docker with the following command: 48 | 49 | ```bash 50 | docker run -p3000:3000 theajax/golem 51 | ``` 52 | 53 | ## 🗺️ Roadmap: 54 | 55 | - 📄 External knowledge (documents, websites, etc.) support 56 | - 🤖 Additional AI language models support 57 | - 🌍 Multi-language support 58 | - 🔌 Plugins support 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /unocss.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'unocss' 2 | import transformerDirectives from '@unocss/transformer-directives' 3 | 4 | export default defineConfig({ 5 | shortcuts: [ 6 | { 'text-color': 'text-gray-7 dark:text-gray-2' }, 7 | { 'text-color-lighter': 'text-gray-5 dark:text-gray-4' }, 8 | ], 9 | rules: [ 10 | [/^bg-([\d]+)\/(\d+)/, ([_, color, opacity]) => ({ 11 | '--gp-bg-opacity': opacity, 12 | 'background-color': `rgba(var(--color-primary-${color}), ${Number(opacity) / 100})`, 13 | })], 14 | 15 | [/^shadow-([\d]+)\/(\d+)/, ([_, color, opacity]) => ({ 16 | '--gp-shadow-opacity': opacity, 17 | '--un-shadow-color': `rgba(var(--color-primary-${color}), ${Number(opacity) / 100})`, 18 | })], 19 | ], 20 | theme: { 21 | colors: { 22 | primary: { 23 | 50: 'rgb(var(--color-primary-50))', 24 | 100: 'rgb(var(--color-primary-100))', 25 | 200: 'rgb(var(--color-primary-200))', 26 | 300: 'rgb(var(--color-primary-300))', 27 | 400: 'rgb(var(--color-primary-400))', 28 | 500: 'rgb(var(--color-primary-500))', 29 | 600: 'rgb(var(--color-primary-600))', 30 | 700: 'rgb(var(--color-primary-700))', 31 | 800: 'rgb(var(--color-primary-800))', 32 | 900: 'rgb(var(--color-primary-900))', 33 | DEFAULT: 'rgb(var(--color-primary-500))', 34 | // 50: '#eadbf0', 35 | // 100: '#efd8f3', 36 | // 200: '#eed0f0', 37 | // 300: '#e0b1e7', 38 | // 400: '#cd83d8', 39 | // 500: '#b95ec9', 40 | // 600: '#a741aa', 41 | // 700: '#903597', 42 | // 800: '#762d7c', 43 | // 900: '#672862', 44 | // DEFAULT: '#b95ec9', 45 | }, 46 | }, 47 | }, 48 | transformers: [ 49 | transformerDirectives(), 50 | ], 51 | }) 52 | -------------------------------------------------------------------------------- /pages/chat/share/[conversationId].vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 65 | -------------------------------------------------------------------------------- /server/embedding/index.ts: -------------------------------------------------------------------------------- 1 | import openai from 'openai' 2 | import pLimit from 'p-limit' 3 | import { splitMarkdownByHeadingLevel } from '../markdown' 4 | 5 | export const createOpenAIClient = () => { 6 | const config = new openai.Configuration({ 7 | apiKey: process.env.OPENAI_API_KEY, 8 | }) 9 | return new openai.OpenAIApi(config) 10 | } 11 | 12 | /** 13 | * Create an embedding for a given text input. 14 | * @param {string} content 15 | * @returns {Promise} 16 | * @throws {Error} if the request is invalid 17 | * */ 18 | export const createEmbedding = async (content: string) => { 19 | const client = createOpenAIClient() 20 | 21 | const embedding = await client.createEmbedding({ 22 | model: 'text-embedding-ada-002', 23 | input: content, 24 | }) 25 | 26 | return embedding.data.data[0].embedding 27 | } 28 | 29 | export async function indexDocuments( 30 | documentList: { url: string; content: string }[], 31 | ) { 32 | const documentsWithSections = await Promise.all( 33 | documentList.map(async document => ({ 34 | ...document, 35 | // Split markdown into sections of ## headings 36 | sections: splitMarkdownByHeadingLevel(document.content, 3), 37 | fullEmbedding: await createEmbedding(document.content), 38 | })), 39 | ) 40 | 41 | // Sections flattened 42 | const sections = documentsWithSections 43 | .reduce((acc, val) => { 44 | const sectionList = val.sections.map((section) => { 45 | const { sections: _, content: __, ...file } = val 46 | return { ...file, section } 47 | }) 48 | return [...acc, ...sectionList] 49 | }, [] as { fullEmbedding: number[]; section: string; url: string }[]) 50 | 51 | // Create embeddings for each section 52 | const limit = pLimit(20) 53 | 54 | const fileEmbeddingsFetch = sections.map(section => limit(async () => { 55 | const embedding = await createEmbedding(section.section) 56 | return { ...section, embedding } 57 | })) 58 | 59 | const embeddings = await Promise.all(fileEmbeddingsFetch) 60 | return embeddings 61 | } 62 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /composables/knowledge.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import type { types } from '~~/utils/types' 3 | 4 | export const useKnowledge = () => { 5 | const db = useIDB() 6 | const knowledgeList = useState(() => null) 7 | 8 | async function listKnowledge() { 9 | // Get only the title, type, id, and updatedAt fields 10 | return await db.table('knowledge').toArray() as types.KnowledgeItem[] 11 | } 12 | 13 | async function addKnowledgeItem(item: types.KnowledgeItem) { 14 | const newItem = { 15 | ...item, 16 | updatedAt: new Date(), 17 | } 18 | const newKey = await db.table('knowledge').add(newItem) 19 | if (!newKey) { 20 | throw new Error('Failed to create knowledge item') 21 | } 22 | await updateKnowledgeList() 23 | } 24 | 25 | async function deleteKnowledgeItem(id: string) { 26 | await db.table('knowledge').delete(id) 27 | await updateKnowledgeList() 28 | } 29 | 30 | async function extractFromUrl(options: { 31 | url: string 32 | title: string 33 | }) { 34 | const response = await $fetch('/api/knowledge', { 35 | method: 'POST', 36 | body: { 37 | type: 'url', 38 | url: options.url, 39 | }, 40 | }) 41 | const data = response as types.WebScraperResult 42 | const knowledgeItem = { 43 | id: nanoid(), 44 | title: data.title, 45 | type: 'url', 46 | sections: [ 47 | { 48 | content: data.markdown, 49 | url: options.url, 50 | }, 51 | ], 52 | metadata: { 53 | favicon: data.favicon, 54 | }, 55 | updatedAt: new Date(), 56 | createdAt: new Date(), 57 | } 58 | await addKnowledgeItem(knowledgeItem) 59 | } 60 | 61 | async function updateKnowledgeList() { 62 | knowledgeList.value = await listKnowledge() 63 | } 64 | 65 | return { 66 | listKnowledge, 67 | addKnowledgeItem, 68 | extractFromUrl, 69 | updateKnowledgeList, 70 | deleteKnowledgeItem, 71 | knowledgeList, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /utils/types.ts: -------------------------------------------------------------------------------- 1 | import type { ChatMessage } from 'chatgpt-web' 2 | 3 | export namespace types { 4 | export interface Conversation { 5 | id: string 6 | title: string 7 | messages: Message[] 8 | knowledge: string[] 9 | createdAt: Date 10 | updatedAt: Date 11 | type?: 'chat' | 'embbeded' 12 | systemMessage?: string 13 | metadata?: ConversationMetadata 14 | settings?: ConversationSettings 15 | } 16 | 17 | export interface ConversationMetadata { 18 | favorite?: boolean 19 | } 20 | 21 | export interface ConversationSettings { 22 | personaId?: string 23 | model?: string | null 24 | maxTokens?: number | null 25 | creativity?: Creativity | null 26 | } 27 | 28 | export interface Message extends ChatMessage { 29 | updatedAt: Date 30 | createdAt: Date 31 | isError?: boolean 32 | metadata?: MessageMetadata 33 | actions?: any[] 34 | } 35 | 36 | export interface MessageMetadata { 37 | favorite?: boolean 38 | } 39 | 40 | export interface Persona { 41 | title: string 42 | instructions: string 43 | id: string 44 | } 45 | 46 | export interface KnowledgeItem { 47 | id: string 48 | title: string 49 | type: string 50 | sections: { content: string; embedding?: number[]; url?: string }[] 51 | updatedAt: Date 52 | metadata: any 53 | } 54 | 55 | export interface WebScraperResult { 56 | url: string 57 | markdown: string 58 | favicon: string 59 | title: string 60 | } 61 | 62 | export type Creativity = 'none' | 'normal' | 'high' 63 | 64 | // Deta namespace 65 | export namespace deta { 66 | export interface Conversation { 67 | key: string 68 | title: string 69 | updatedAt: string 70 | createdAt: string 71 | metadata?: ConversationMetadata 72 | } 73 | 74 | export interface Message { 75 | key: string 76 | conversationId: string 77 | text: string 78 | role: string 79 | updatedAt: string 80 | createdAt: string 81 | parentMessageId?: string 82 | } 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /components/dialog.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 35 | 36 | 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare", 10 | "prepare": "esno scripts/prepare.ts" 11 | }, 12 | "devDependencies": { 13 | "@antfu/eslint-config": "^0.36.0", 14 | "@formkit/auto-animate": "1.0.0-beta.6", 15 | "@iconify-json/eos-icons": "^1.1.6", 16 | "@iconify-json/fluent-emoji": "^1.1.12", 17 | "@iconify-json/tabler": "^1.1.68", 18 | "@nuxtjs/color-mode": "^3.2.0", 19 | "@types/crawler": "^1.2.2", 20 | "@types/fs-extra": "^11.0.1", 21 | "@types/marked": "^4.0.8", 22 | "@types/mdast": "^3.0.10", 23 | "@types/prismjs": "^1.26.0", 24 | "@types/tinycolor2": "^1.4.3", 25 | "@unocss/nuxt": "^0.50.4", 26 | "@unocss/transformer-directives": "^0.50.6", 27 | "eslint": "^8.36.0", 28 | "esno": "^0.16.3", 29 | "fs-extra": "^11.1.1", 30 | "ignore-dependency-scripts": "^1.0.1", 31 | "nuxt": "3.3.1", 32 | "theme-vitesse": "^0.6.4", 33 | "typescript": "^4.9.5" 34 | }, 35 | "dependencies": { 36 | "@bobthered/tailwindcss-palette-generator": "^3.1.1", 37 | "@dqbd/tiktoken": "^1.0.3", 38 | "@kevinmarrec/nuxt-pwa": "^0.17.0", 39 | "@trpc/client": "^10.18.0", 40 | "@trpc/server": "^10.18.0", 41 | "@vueuse/nuxt": "^9.13.0", 42 | "axios": "^1.3.4", 43 | "beautiful-dom": "^1.0.9", 44 | "chatgpt": "^5.0.10", 45 | "chatgpt-web": "1.0.10", 46 | "consola": "^2.15.3", 47 | "crawler": "^1.4.0", 48 | "cross-fetch": "^3.1.5", 49 | "deta": "^1.1.0", 50 | "dexie": "^3.2.3", 51 | "eventsource-parser": "^1.0.0", 52 | "floating-vue": "2.0.0-beta.20", 53 | "fuse.js": "^6.6.2", 54 | "gpt-token-utils": "^1.2.0", 55 | "hyperid": "^3.1.1", 56 | "lang-detector": "^1.0.6", 57 | "marked": "^4.2.12", 58 | "mdast": "^3.0.0", 59 | "nanoid": "^4.0.2", 60 | "node-fetch": "^3.3.1", 61 | "node-html-markdown": "^1.3.0", 62 | "openai": "^3.2.1", 63 | "p-limit": "^4.0.0", 64 | "prismjs": "^1.29.0", 65 | "remark": "^14.0.2", 66 | "remark-gfm": "^3.0.1", 67 | "shiki-es": "^0.2.0", 68 | "tinycolor2": "^1.6.0", 69 | "trpc-nuxt": "^0.8.0", 70 | "undici": "^5.21.0", 71 | "zod": "^3.21.4" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 39 | 40 | 68 | -------------------------------------------------------------------------------- /pages/history.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 79 | -------------------------------------------------------------------------------- /public/image/logo-dark-square-transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /components/app-chat/settings-value.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 75 | -------------------------------------------------------------------------------- /public/image/logo-light-square-transparent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /pages/knowledge.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 72 | -------------------------------------------------------------------------------- /public/image/logo-dark-square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /composables/idb.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie' 2 | 3 | export function useIDB(options?: { disableStorage: boolean }) { 4 | const isStorageDisabled = useState(() => false) 5 | 6 | if (options?.disableStorage) { 7 | isStorageDisabled.value = true 8 | } 9 | 10 | // Check if IDB and/or localStorage is available 11 | const isStorageAvailable = () => { 12 | if (isStorageDisabled.value) { 13 | return false 14 | } 15 | try { 16 | // LocalStorage 17 | const x = '__storage_test__' 18 | localStorage.setItem(x, x) 19 | localStorage.removeItem(x) 20 | // IndexedDB 21 | const db = new Dexie('test') 22 | db.version(1).stores({ 23 | test: 'id', 24 | }) 25 | return true 26 | } 27 | catch (e) { 28 | return false 29 | } 30 | } 31 | 32 | if (isStorageAvailable() && process.client) { 33 | const db = new Dexie('gepeto') 34 | 35 | db.version(3).stores({ 36 | knowledge: 'id, title, type, sections, metadata, updatedAt, createdAt', 37 | conversations: 'id, title, messages, metadata, settings, createdAt, updatedAt', 38 | personas: 'id, title, instructions', 39 | }) 40 | 41 | return db 42 | } 43 | else { 44 | // Return a mock database that uses useState instead of IDB 45 | return { 46 | table: (tableName: string) => { 47 | const state = useState(`idb-${tableName}`, () => []) 48 | 49 | return { 50 | async add(item: any) { 51 | state.value.push(item) 52 | return item.id 53 | }, 54 | 55 | async delete(id: string) { 56 | state.value = state.value.filter((item: any) => item.id !== id) 57 | }, 58 | 59 | async toArray() { 60 | return state.value 61 | }, 62 | 63 | async get(id: string) { 64 | return state.value.find((item: any) => item.id === id) 65 | }, 66 | 67 | async put(item: any) { 68 | const index = state.value.findIndex((i: any) => i.id === item.id) 69 | state.value[index] = item 70 | }, 71 | 72 | async clear() { 73 | state.value = [] 74 | }, 75 | } 76 | }, 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /public/image/logo-light-square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.github/workflows/release-preview.yml: -------------------------------------------------------------------------------- 1 | name: Deploy preview environment 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, synchronize, reopened ] 6 | 7 | jobs: 8 | release: 9 | name: Push application to Vercel 10 | runs-on: ubuntu-latest 11 | steps: 12 | - id: script 13 | uses: actions/github-script@f05a81df23035049204b043b50c3322045ce7eb3 # pin@v3 14 | with: 15 | script: | 16 | const isPr = [ 'pull_request', 'pull_request_target' ].includes(context.eventName) 17 | core.setOutput('ref', isPr ? context.payload.pull_request.head.ref : context.ref) 18 | core.setOutput('repo', isPr ? context.payload.pull_request.head.repo.full_name : context.repo.full_name) 19 | core.setOutput('pr-number', context.payload.pull_request.number) 20 | core.setOutput('repo-name', context.payload.pull_request.head.repo.full_name.split('/')[1]) 21 | 22 | - name: Get Token 23 | id: auth 24 | uses: peter-murray/workflow-application-token-action@e8782d687a306fb13d733244d0f2a50e272d3752 # pin@v1 25 | with: 26 | application_id: ${{ secrets.APPLICATION_ID }} 27 | application_private_key: ${{ secrets.APPLICATION_PRIVATE_KEY }} 28 | 29 | - name: Checkout 30 | uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3 31 | with: 32 | fetch-depth: 0 33 | ref: ${{ steps.script.outputs.ref }} 34 | repository: ${{ steps.script.outputs.repo }} 35 | 36 | - name: Deploy to Vercel 37 | id: deploy 38 | uses: BetaHuhn/deploy-to-vercel-action@v1 # pin@ffcc89a6d79de43d964945ce053395c2958610b1 39 | env: 40 | DOMAIN: golem-${{ steps.script.outputs.pr-number }}.vercel.app 41 | with: 42 | GITHUB_TOKEN: ${{ steps.auth.outputs.token }} 43 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 44 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 45 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 46 | VERCEL_SCOPE: ${{ secrets.VERCEL_ORG_ID }} 47 | PR_PREVIEW_DOMAIN: ${{ env.DOMAIN }} 48 | BUILD_ENV: | 49 | REDIRECT_URL=${{ env.DOMAIN }} 50 | GOLEM_PASSWORD=${{ secrets.GEPPETO_PASSWORD }}#${{ steps.script.outputs.pr-number }} 51 | APP_VERSION=${{ steps.script.outputs.ref }}#${{ steps.script.outputs.pr-number }} 52 | 53 | - name: Deta Space Deployment 54 | uses: neobrains/space-deployment-github-action@v0.5 55 | with: 56 | access_token: ${{ secrets.DETA_ACCESS_TOKEN }} 57 | project_id: ${{ secrets.DETA_PROJECT_ID }} 58 | space_push: true 59 | 60 | -------------------------------------------------------------------------------- /components/go/button.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 79 | -------------------------------------------------------------------------------- /components/knowledge/manager.vue: -------------------------------------------------------------------------------- 1 | 82 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | pwa: { 3 | icon: { 4 | fileName: 'android-chrome-512x512.png', 5 | }, 6 | manifest: { 7 | background_color: '#f5f5f5', 8 | name: 'Golem', 9 | categories: ['productivity', 'education'], 10 | description: 'Golem is an open-source, amazingly crafted conversational UI and alternative to ChatGPT.', 11 | display: 'standalone', 12 | lang: 'en', 13 | id: `golem-${new Date().getTime()}`, 14 | theme_color: '#3f3f3f', 15 | }, 16 | meta: { 17 | ogDescription: 'Golem is an open-source, amazingly crafted conversational UI and alternative to ChatGPT.', 18 | ogTitle: 'Golem', 19 | ogHost: 'https://golem.chat/', 20 | ogImage: '/og-image.png', 21 | ogUrl: 'https://golem.chat/', 22 | title: 'Golem', 23 | author: 'Henrique Cunha', 24 | description: 'Golem is an open-source, amazingly crafted conversational UI and alternative to ChatGPT.', 25 | lang: 'en', 26 | ogSiteName: 'app.golem.chat', 27 | twitterCard: 'summary_large_image', 28 | twitterSite: 'golem.chat', 29 | twitterCreator: '@henrycunh', 30 | 31 | }, 32 | }, 33 | 34 | css: ['~/assets/css/main.css'], 35 | experimental: { 36 | reactivityTransform: true, 37 | }, 38 | modules: [ 39 | '@unocss/nuxt', 40 | '@vueuse/nuxt', 41 | '@nuxtjs/color-mode', 42 | '@kevinmarrec/nuxt-pwa', 43 | ], 44 | colorMode: { 45 | classSuffix: '', 46 | }, 47 | unocss: { 48 | attributify: true, 49 | uno: true, 50 | icons: true, 51 | webFonts: { 52 | fonts: { 53 | code: 'DM Mono:400', 54 | text: 'Rubik:400,700', 55 | title: 'Space Grotesk:400,700', 56 | }, 57 | }, 58 | }, 59 | vite: { 60 | define: { 61 | 'process.env.VSCODE_TEXTMATE_DEBUG': 'false', 62 | '__DEV__': process.env.NODE_ENV === 'development', 63 | }, 64 | 65 | }, 66 | hooks: { 67 | 'vite:extendConfig': function (config, { isServer }) { 68 | if (isServer) { 69 | const alias = config.resolve!.alias as Record 70 | for (const dep of ['shiki-es', 'fuse.js']) { 71 | alias[dep] = 'unenv/runtime/mock/proxy' 72 | } 73 | } 74 | }, 75 | }, 76 | app: { 77 | pageTransition: { name: 'page', mode: 'out-in' }, 78 | }, 79 | runtimeConfig: { 80 | detaKey: process.env.DETA_PROJECT_KEY, 81 | apiKey: process.env.OPENAI_API_KEY, 82 | password: process.env.GOLEM_PASSWORD, 83 | }, 84 | build: { 85 | transpile: ['trpc-nuxt', 'dexie'], 86 | }, 87 | }) 88 | -------------------------------------------------------------------------------- /components/go/long-press-button.vue: -------------------------------------------------------------------------------- 1 | 5 | 49 | 50 | 100 | 101 | 108 | -------------------------------------------------------------------------------- /components/app-chat/history-container.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 109 | -------------------------------------------------------------------------------- /composables/setup.ts: -------------------------------------------------------------------------------- 1 | import pLimit from 'p-limit' 2 | import type { types } from '~~/utils/types' 3 | export async function useSetup(options?: { disableStorage: boolean; embedded?: boolean }) { 4 | if (process.client) { 5 | const { isOnSharePage } = useSession() 6 | const { setPalette } = useAppearance() 7 | useColorMode() 8 | setPalette() 9 | useShiki().setupShikiLanguages() 10 | if (options?.disableStorage) { 11 | useIDB({ disableStorage: true }) 12 | } 13 | 14 | const { isDetaEnabled, deta } = useDeta() 15 | 16 | const { 17 | currentConversation, 18 | conversationList, 19 | createConversation, 20 | updateConversationList, 21 | switchConversation, 22 | } = useConversations() 23 | 24 | const { initPersonaList, personaList } = usePersona() 25 | 26 | watchEffect(async () => { 27 | if (personaList.value.length === 0) { 28 | await initPersonaList() 29 | } 30 | }) 31 | 32 | const { updateKnowledgeList } = useKnowledge() 33 | 34 | await updateKnowledgeList() 35 | if (options?.embedded) { 36 | logger.info('Embedded mode enabled') 37 | } 38 | 39 | if (isDetaEnabled.value && !isOnSharePage.value) { 40 | logger.info('Deta is enabled, syncing conversations') 41 | deta.conversation.list().then((conversations) => { 42 | const limit = pLimit(10) 43 | logger.info(`Syncing ${conversations.length} conversations`) 44 | Promise.all( 45 | conversations.map(conversation => limit(() => deta.conversation.sync(conversation.key as string))), 46 | ).then(() => { 47 | logger.info('Finished syncing conversations') 48 | updateConversationList() 49 | }) 50 | }) 51 | } 52 | 53 | watchEffect(async () => { 54 | if (currentConversation.value === null) { 55 | if (conversationList.value === null) { 56 | await updateConversationList() 57 | return 58 | } 59 | 60 | if (conversationList.value && conversationList.value.length === 0 && !options?.embedded) { 61 | logger.info('No conversations present, creating a new one') 62 | const newConversation = await createConversation('Untitled Conversation') 63 | await switchConversation(newConversation.id) 64 | } 65 | else if (!options?.embedded) { 66 | logger.info('Switching to most recent conversation') 67 | const mostRecentConversation = conversationList.value.sort((a: types.Conversation, b: types.Conversation) => { 68 | return b.updatedAt.getTime() - a.updatedAt.getTime() 69 | })[0] 70 | await switchConversation(mostRecentConversation.id) 71 | } 72 | } 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /components/app-messages-sidebar/index.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 91 | -------------------------------------------------------------------------------- /composables/shiki.ts: -------------------------------------------------------------------------------- 1 | import type { Highlighter, Lang } from 'shiki-es' 2 | 3 | const langs = [ 4 | 'js', 5 | 'css', 6 | 'html', 7 | 'json', 8 | 'yaml', 9 | 'md', 10 | 'rust', 11 | 'go', 12 | 'python', 13 | 'vue', 14 | 'ruby', 15 | ] as Lang[] 16 | 17 | const HTML_ENTITIES = { 18 | '<': '<', 19 | '>': '>', 20 | '&': '&', 21 | '\'': ''', 22 | '"': '"', 23 | } as Record 24 | 25 | export function useShiki() { 26 | const shiki = useState('shiki-highlighter', () => null) 27 | const registeredLang = useState('shiki-registered-lang', () => new Map()) 28 | const shikiImport = useState | undefined>('shiki-import', () => undefined) 29 | 30 | function initShiki() { 31 | if (!shikiImport.value) { 32 | shikiImport.value = import('shiki-es') 33 | .then(async (r) => { 34 | r.setCDN('/shiki/') 35 | shiki.value = await r.getHighlighter({ 36 | themes: [ 37 | 'vitesse-dark', 38 | 'vitesse-light', 39 | ], 40 | langs, 41 | }) 42 | }) 43 | } 44 | 45 | if (!shiki.value) { 46 | return undefined 47 | } 48 | } 49 | 50 | function useHighlighter(lang: Lang) { 51 | initShiki() 52 | 53 | if (!registeredLang.value.get(lang) && shiki.value) { 54 | shiki.value.loadLanguage(lang) 55 | .then(() => { 56 | registeredLang.value.set(lang, true) 57 | }) 58 | .catch(() => { 59 | const fallbackLang = 'md' 60 | shiki.value?.loadLanguage(fallbackLang).then(() => { 61 | registeredLang.value.set(fallbackLang, true) 62 | }) 63 | }) 64 | return undefined 65 | } 66 | 67 | return shiki.value 68 | } 69 | 70 | function useShikiTheme() { 71 | return useColorMode().value === 'dark' ? 'vitesse-dark' : 'vitesse-light' 72 | } 73 | 74 | function escapeHtml(text: string) { 75 | return text.replace(/[<>&'"]/g, ch => HTML_ENTITIES[ch]) 76 | } 77 | 78 | async function highlightCode(code: string, lang: Lang) { 79 | const shiki = useHighlighter(lang) 80 | if (!shiki) { 81 | return escapeHtml(code) 82 | } 83 | const theme = useShikiTheme() 84 | return shiki.codeToHtml(code, { 85 | lang, 86 | theme, 87 | }) 88 | } 89 | 90 | function setupShikiLanguages() { 91 | initShiki() 92 | if (!shiki.value) { 93 | return 94 | } 95 | for (const lang of langs) { 96 | shiki.value.loadLanguage(lang) 97 | } 98 | } 99 | 100 | return { 101 | highlightCode, 102 | setupShikiLanguages, 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /server/trpc/routers/deta/conversation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { publicProcedure, router } from '~/server/trpc/trpc' 3 | import type { types } from '~~/utils/types' 4 | 5 | export const conversationRouter = router({ 6 | 7 | list: publicProcedure 8 | .query(async ({ ctx }) => { 9 | const { items } = await ctx.deta.conversations.fetch() 10 | return items as any as types.deta.Conversation[] 11 | }), 12 | 13 | get: publicProcedure 14 | .input( 15 | z.object({ 16 | id: z.string(), 17 | }), 18 | ) 19 | .query(async ({ ctx, input }) => { 20 | const { id } = input 21 | const conversation = await ctx.deta.conversations.get(id) 22 | return conversation as any as types.deta.Conversation 23 | }), 24 | 25 | create: publicProcedure 26 | .input( 27 | z.object({ 28 | title: z.string(), 29 | id: z.string(), 30 | updatedAt: z.string().or(z.date()), 31 | createdAt: z.string().or(z.date()), 32 | }), 33 | ) 34 | .mutation(async ({ ctx, input }) => { 35 | const { title, id, updatedAt, createdAt } = input 36 | const conversation = await ctx.deta.conversations.insert({ 37 | key: id, 38 | title, 39 | updatedAt: updatedAt as string, 40 | createdAt: createdAt as string, 41 | }) 42 | return conversation 43 | }), 44 | 45 | update: publicProcedure 46 | .input( 47 | z.object({ 48 | title: z.string().optional(), 49 | id: z.string(), 50 | metadata: z.any().optional(), 51 | updatedAt: z.string().or(z.date()).optional(), 52 | createdAt: z.string().or(z.date()).optional(), 53 | }), 54 | ) 55 | .mutation(async ({ ctx, input }) => { 56 | const { title, id, updatedAt, createdAt, metadata } = input 57 | const patch = new Map() 58 | if (title) { 59 | patch.set('title', title) 60 | } 61 | if (updatedAt) { 62 | patch.set('updatedAt', updatedAt) 63 | } 64 | if (createdAt) { 65 | patch.set('createdAt', createdAt) 66 | } 67 | if (metadata !== undefined) { 68 | patch.set('metadata', metadata) 69 | } 70 | const conversation = await ctx.deta.conversations.put({ 71 | key: id, 72 | ...Object.fromEntries(patch), 73 | }) 74 | return conversation 75 | }), 76 | 77 | delete: publicProcedure 78 | .input( 79 | z.object({ 80 | id: z.string(), 81 | }), 82 | ) 83 | .mutation(async ({ ctx, input }) => { 84 | const { id } = input 85 | const conversation = await ctx.deta.conversations.delete(id) 86 | return conversation 87 | }), 88 | }) 89 | 90 | export type ConversationRouter = typeof conversationRouter 91 | -------------------------------------------------------------------------------- /components/markdown/renderer.vue: -------------------------------------------------------------------------------- 1 | 94 | -------------------------------------------------------------------------------- /components/app-navbar/index.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 115 | -------------------------------------------------------------------------------- /pages/settings/api-key.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 105 | -------------------------------------------------------------------------------- /pages/settings.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 112 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 107 | -------------------------------------------------------------------------------- /components/markdown/code-block.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 128 | -------------------------------------------------------------------------------- /server/trpc/routers/deta/message.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { publicProcedure, router } from '~/server/trpc/trpc' 3 | import type { types } from '~~/utils/types' 4 | import { parseDateFields } from '~/utils/object' 5 | 6 | export const messageRouter = router({ 7 | 8 | list: publicProcedure 9 | .input( 10 | z.object({ 11 | conversationId: z.string().optional(), 12 | }), 13 | ) 14 | .query(async ({ ctx, input }) => { 15 | const query: Record = {} 16 | if (input.conversationId) { 17 | query.conversationId = input.conversationId 18 | } 19 | const { items } = await ctx.deta.messages.fetch(query) 20 | return items.map(item => ({ 21 | ...item as any, 22 | id: item.key as string, 23 | })) 24 | }), 25 | 26 | get: publicProcedure 27 | .input( 28 | z.object({ 29 | id: z.string(), 30 | }), 31 | ) 32 | .query(async ({ ctx, input }) => { 33 | const { id } = input 34 | const conversation = await ctx.deta.messages.get(id) 35 | return parseDateFields( 36 | conversation as any as types.deta.Message, 37 | ['updatedAt', 'createdAt'] as const, 38 | ) 39 | }), 40 | 41 | create: publicProcedure 42 | .input( 43 | z.object({ 44 | id: z.string(), 45 | updatedAt: z.string().or(z.string().or(z.date())), 46 | createdAt: z.string().or(z.string().or(z.date())), 47 | conversationId: z.string().optional(), 48 | text: z.string(), 49 | role: z.string(), 50 | parentMessageId: z.string().optional(), 51 | }), 52 | ) 53 | .mutation(async ({ ctx, input }) => { 54 | const { id, updatedAt, createdAt, conversationId, text, role, parentMessageId } = input 55 | const message = await ctx.deta.messages.insert({ 56 | key: id, 57 | conversationId, 58 | updatedAt: updatedAt as string, 59 | createdAt: createdAt as string, 60 | text, 61 | role, 62 | parentMessageId, 63 | }) 64 | return message 65 | }), 66 | 67 | update: publicProcedure 68 | .input( 69 | z.object({ 70 | id: z.string(), 71 | updatedAt: z.string().or(z.date()).optional(), 72 | createdAt: z.string().or(z.date()).optional(), 73 | conversationId: z.string().optional(), 74 | text: z.string().optional(), 75 | metadata: z.any().optional(), 76 | role: z.any().optional(), 77 | parentMessageId: z.string().optional(), 78 | }), 79 | ) 80 | .mutation(async ({ ctx, input }) => { 81 | const { updatedAt, createdAt } = input 82 | const patch = new Map() 83 | if (updatedAt) { 84 | patch.set('updatedAt', updatedAt) 85 | } 86 | if (createdAt) { 87 | patch.set('createdAt', createdAt) 88 | } 89 | for (const field of ['conversationId', 'text', 'role', 'parentMessageId', 'metadata'] as const) { 90 | if (input[field]) { 91 | patch.set(field, input[field]) 92 | } 93 | } 94 | const message = await ctx.deta.messages.put({ 95 | key: input.id, 96 | ...Object.fromEntries(patch), 97 | }) 98 | return message 99 | }), 100 | 101 | delete: publicProcedure 102 | .input( 103 | z.object({ 104 | id: z.string(), 105 | }), 106 | ) 107 | .mutation(async ({ ctx, input }) => { 108 | const { id } = input 109 | const conversation = await ctx.deta.messages.delete(id) 110 | return conversation 111 | }), 112 | }) 113 | 114 | export type MessageRouter = typeof messageRouter 115 | -------------------------------------------------------------------------------- /.github/workflows/release-staging.yml: -------------------------------------------------------------------------------- 1 | name: Deploy staging environment 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | create-release: 10 | name: Create release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Get Token 14 | id: auth 15 | uses: peter-murray/workflow-application-token-action@e8782d687a306fb13d733244d0f2a50e272d3752 # pin@v1 16 | with: 17 | application_id: ${{ secrets.APPLICATION_ID }} 18 | application_private_key: ${{ secrets.APPLICATION_PRIVATE_KEY }} 19 | 20 | - name: Checkout 21 | uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3 22 | with: 23 | fetch-depth: 0 24 | ref: ${{ steps.script.outputs.ref }} 25 | repository: ${{ steps.script.outputs.repo }} 26 | 27 | - uses: clicampo/action-publish-semver-release@v1 28 | id: release 29 | with: 30 | github-token: ${{ steps.auth.outputs.token }} 31 | git-committer-name: Release bot 32 | git-committer-email: release@bot.com 33 | # slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} 34 | # project-url: ${{ github.server_url }}/${{ github.repository }} 35 | # production-action-url: ${{ github.server_url }}/${{ github.repository }}/actions/workflows/release-production.yml 36 | 37 | - name: Create release artifacts 38 | # Create a artifact with the release version, the name is the commit hash 39 | run: | 40 | mkdir -p artifacts 41 | echo "${{ steps.release.outputs.next-version }}" > artifacts/${{ github.sha }}.txt 42 | 43 | - name: Upload release artifacts 44 | uses: actions/upload-artifact@v2 45 | with: 46 | name: release-artifacts 47 | path: artifacts 48 | 49 | publish-to-vercel: 50 | name: Push application to Vercel 51 | runs-on: ubuntu-latest 52 | needs: create-release 53 | steps: 54 | - name: Get Token 55 | id: auth 56 | uses: peter-murray/workflow-application-token-action@e8782d687a306fb13d733244d0f2a50e272d3752 # pin@v1 57 | with: 58 | application_id: ${{ secrets.APPLICATION_ID }} 59 | application_private_key: ${{ secrets.APPLICATION_PRIVATE_KEY }} 60 | 61 | - name: Checkout 62 | uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3 63 | with: 64 | fetch-depth: 0 65 | 66 | - name: Deploy to Vercel 67 | id: deploy 68 | uses: BetaHuhn/deploy-to-vercel-action@v1 # pin@ffcc89a6d79de43d964945ce053395c2958610b1 69 | env: 70 | DOMAIN: golem-chat-staging.vercel.app 71 | with: 72 | GITHUB_TOKEN: ${{ steps.auth.outputs.token }} 73 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 74 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 75 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 76 | VERCEL_SCOPE: ${{ secrets.VERCEL_ORG_ID }} 77 | PR_PREVIEW_DOMAIN: ${{ env.DOMAIN }} 78 | ALIAS_DOMAINS: | 79 | ${{ env.DOMAIN }} 80 | 81 | 82 | publish-to-deta-space: 83 | name: Push application to Deta Space 84 | runs-on: ubuntu-latest 85 | needs: create-release 86 | steps: 87 | - name: Get Token 88 | id: auth 89 | uses: peter-murray/workflow-application-token-action@e8782d687a306fb13d733244d0f2a50e272d3752 # pin@v1 90 | with: 91 | application_id: ${{ secrets.APPLICATION_ID }} 92 | application_private_key: ${{ secrets.APPLICATION_PRIVATE_KEY }} 93 | 94 | - name: Checkout 95 | uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3 96 | with: 97 | fetch-depth: 0 98 | 99 | - name: Get release artifacts 100 | uses: actions/download-artifact@v2 101 | with: 102 | name: release-artifacts 103 | path: artifacts 104 | 105 | - name: Read release version # and set as a output 106 | id: release 107 | run: | 108 | echo "::set-output name=release-version::$(cat artifacts/${{ github.sha }}.txt)" 109 | 110 | - name: Deta Space Deployment 111 | uses: henrycunh/space-deployment-github-action@05e4e73d6801abed32a3580c8e794ea790fab827 112 | with: 113 | access_token: ${{ secrets.DETA_ACCESS_TOKEN }} 114 | project_id: ${{ secrets.DETA_PROJECT_ID }} 115 | release_version: ${{ steps.release.outputs.release-version }} 116 | use_experimental_build_pipeline: true 117 | space_push: true -------------------------------------------------------------------------------- /server/from/scraping.ts: -------------------------------------------------------------------------------- 1 | import stream from 'node:stream' 2 | import Crawler from 'crawler' 3 | import { NodeHtmlMarkdown } from 'node-html-markdown' 4 | import type { types } from '~~/utils/types' 5 | 6 | const nhm = new NodeHtmlMarkdown() 7 | 8 | function extractContentAndLinks($: cheerio.CheerioAPI) { 9 | try { 10 | // Get page title and favicon 11 | const title = $('title').text() 12 | const favicon = $('link[rel="icon"]').attr('href') 13 | 14 | const allowedTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code'] 15 | const filteredHtml = $('*') 16 | .filter((i, el) => el.type === 'tag' && allowedTags.includes(el.tagName)) 17 | .map((i, el) => $.html(el)) 18 | .get() 19 | .join('') 20 | const markdown = nhm.translate(filteredHtml) 21 | 22 | // Remove images 23 | const markdownClean = markdown 24 | .replace(/!\[.*\]\(.*\)/g, '') 25 | // Remove everything before a heading 26 | .replace(/^(?!#).*/g, '') 27 | // Remove headings that are just links 28 | .replace(/#{1,6} \[.*\]\(.*\)/g, '') 29 | // Remove empty headings 30 | .replace(/#{1,6} ?\n/g, '') 31 | .split('\n') 32 | .map(line => line.trim()) 33 | .filter(Boolean) 34 | .join('\n') 35 | // Get next links with the same domain 36 | const links = $('a') 37 | .filter((i, el) => { 38 | const href = $(el).attr('href') 39 | return Boolean(href?.startsWith('/') && !href.startsWith('//')) 40 | }) 41 | .map((i, el) => $(el).attr('href')) 42 | .toArray() 43 | .map(link => (link as any as string).replace(/[#\?].+/g, '')) 44 | 45 | return { markdown: markdownClean, links, favicon, title } 46 | } 47 | catch (error) { 48 | console.log(error) 49 | return { markdown: '', links: [] } 50 | } 51 | } 52 | 53 | function normalizeLinkList(links: string[], baseUrl: string) { 54 | return links.map((link) => { 55 | const baseUrlWithoutParts = baseUrl.split('/').slice(0, 3).join('/') 56 | const completeLink = link.startsWith('http') ? link : baseUrlWithoutParts + link 57 | // Remove double slashes except for the protocol 58 | const normalizedLink = completeLink 59 | .replace(/([^:]\/)\/+/g, '$1') 60 | .replace(/\/$/, '') 61 | 62 | return normalizedLink 63 | }).filter(link => link.startsWith(baseUrl)) 64 | } 65 | 66 | // Returns a stream of { url, markdown } 67 | export function WebScraper(url: string, options?: { 68 | maxConnections?: number 69 | maxDepth?: number 70 | crawl?: boolean 71 | }) { 72 | // Create a stream 73 | const readable = new stream.Readable({ 74 | objectMode: true, 75 | read() {}, 76 | }) 77 | 78 | const visited = new Set() 79 | const c = new Crawler({ 80 | maxConnections: options?.maxConnections || 5, 81 | callback(error, res, done) { 82 | if (error) { 83 | console.log(error) 84 | } 85 | else { 86 | console.log('Crawling', res.request.uri.href) 87 | const { $, request } = res 88 | const { markdown, links, favicon, title } = extractContentAndLinks($) 89 | const result = { url: request.uri.href, markdown, favicon, title } 90 | readable.push(result) 91 | if (options?.crawl) { 92 | const normalizedLinks = normalizeLinkList(links, url) 93 | normalizedLinks.forEach((link) => { 94 | if (!visited.has(link)) { 95 | visited.add(link) 96 | c.queue(link) 97 | } 98 | }) 99 | } 100 | } 101 | done() 102 | }, 103 | }) 104 | c.queue(url) 105 | 106 | c.on('drain', () => { 107 | readable.emit('end') 108 | }) 109 | 110 | return readable 111 | } 112 | 113 | export async function scrapeUrl(url: string): Promise { 114 | return new Promise((resolve, reject) => { 115 | const readable = WebScraper(url) 116 | let result: types.WebScraperResult 117 | readable.on('data', (data) => { 118 | result = data as types.WebScraperResult 119 | }) 120 | readable.on('end', () => { 121 | resolve(result) 122 | }) 123 | readable.on('error', (error) => { 124 | reject(error) 125 | }) 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /composables/deta.ts: -------------------------------------------------------------------------------- 1 | import pLimit from 'p-limit' 2 | import type { types } from '~~/utils/types' 3 | 4 | export function useDeta() { 5 | const idb = useIDB() 6 | const client = useClient() 7 | const isDetaEnabled = useState('is-deta-enabled', () => false) 8 | 9 | const deta = { 10 | conversation: { 11 | async get(id: string) { 12 | const result = await client.deta.conversations.get.query({ id }) 13 | return parseDateFields(result, ['updatedAt', 'createdAt'] as const) 14 | }, 15 | async create(conversation: types.Conversation) { 16 | logger.info('Creating conversation', conversation) 17 | return await client.deta.conversations.create.mutate(conversation) 18 | }, 19 | async update(conversation: types.Conversation) { 20 | logger.info('Updating conversation', conversation) 21 | return await client.deta.conversations.update.mutate(conversation) 22 | }, 23 | async delete(id: string) { 24 | const res = await client.deta.conversations.delete.mutate({ id }) 25 | const conversationMessageList = await client.deta.messages.list.query({ conversationId: id }) 26 | const messageIds = conversationMessageList.map(item => item.id) 27 | const limit = pLimit(10) 28 | await Promise.all(messageIds.map(id => limit(() => client.deta.messages.delete.mutate({ id })))) 29 | logger.info('Deleted conversation', id, 'and', messageIds.length, 'messages') 30 | return res 31 | }, 32 | async list() { 33 | const conversations = await client.deta.conversations.list.query() 34 | return conversations.map(conversation => parseDateFields(conversation, ['updatedAt', 'createdAt'] as const)) 35 | }, 36 | async sync(id: string) { 37 | const conversation = await deta.conversation.get(id) 38 | if (!conversation) { 39 | throw new DetaError(`Conversation not found with id ${id}`, 'NOT_FOUND') 40 | } 41 | const messages = await client.deta.messages.list.query({ conversationId: id }) 42 | // Check if the conversation exists in the local db 43 | const localConversation = await idb.table('conversations').get(id) 44 | const newConversation = { 45 | id: conversation.key as string, 46 | title: conversation.title, 47 | updatedAt: conversation.updatedAt, 48 | createdAt: conversation.createdAt, 49 | metadata: conversation.metadata, 50 | messages: messages.map(message => parseDateFields(message, ['updatedAt', 'createdAt'] as const)), 51 | } 52 | if (!localConversation) { 53 | // If not, create it 54 | logger.info('Creating conversation', newConversation.id) 55 | await idb.table('conversations').add(newConversation) 56 | } 57 | else { 58 | logger.info('Updating conversation', newConversation.id) 59 | // If it does, update it 60 | try { 61 | await idb.table('conversations').put(newConversation) 62 | } 63 | catch (e) { 64 | logger.error(e) 65 | } 66 | } 67 | }, 68 | }, 69 | message: { 70 | async get(id: string) { 71 | return await client.deta.messages.get.query({ id }) 72 | }, 73 | async create(message: types.Message) { 74 | logger.info('Creating message', message.id) 75 | return await client.deta.messages.create.mutate(message) 76 | }, 77 | update: async (message: types.Message) => { 78 | logger.info('Updating message', message.id) 79 | return await client.deta.messages.update.mutate(message) 80 | }, 81 | delete: async (id: string) => { 82 | logger.info('Deleting message', id) 83 | return await client.deta.messages.delete.mutate({ id }) 84 | }, 85 | }, 86 | } 87 | 88 | return { 89 | isDetaEnabled, 90 | deta, 91 | } 92 | } 93 | 94 | type DetaErrorCode = 'NOT_FOUND' | null 95 | 96 | export class DetaError extends Error { 97 | code: DetaErrorCode = null 98 | constructor(message: string, code: DetaErrorCode) { 99 | super(message) 100 | this.code = code 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /components/app-prompt-input.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 |