Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add chats #49

Merged
merged 7 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pnpm start

### Migrations

- To generate new migrations run: `pnpm db:generate-migration`
- To generate new migrations run: `pnpm db:generate`
- To migrate the database and seed it run: `pnpm db:up`
- To open the database explorer run: `pnpm db:explorer`

Expand Down
3 changes: 2 additions & 1 deletion app.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export default defineAppConfig({
ui: {
primary: 'zinc',
primary: 'neutral',
gray: 'zinc',
input: {
default: {
size: 'lg',
Expand Down
6 changes: 5 additions & 1 deletion components/Card.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<template>
<div
class="rounded-lg border bg-transparent border-stone-300 dark:border-stone-800 dark:hover:border-stone-500 relative overflow-hidden duration-500 hover:border-primary/10 group hover:drop-shadow-md"
class="rounded-lg shadow ring-1 ring-stone-200 dark:ring-neutral-800 bg-stone-100 dark:bg-neutral-900"
:class="{
'cursor-pointer dark:hover:ring-stone-500 hover:ring-stone-300 hover:drop-shadow-md': clickable,
}"
>
<slot />
</div>
Expand All @@ -11,5 +14,6 @@ defineProps<{
href?: string;
title?: string;
subtitle?: string;
clickable?: boolean;
}>();
</script>
6 changes: 6 additions & 0 deletions components/Markdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ defineProps<{
.prose :deep(p) {
@apply m-0;
}

.prose :deep(h1),
.prose :deep(h2),
.prose :deep(h3) {
@apply dark:text-white mb-4 text-2xl;
}
</style>
32 changes: 13 additions & 19 deletions components/Menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,27 @@
<span>captain.ai</span>
</NuxtLink>
<div class="space-y-4">
<div class="px-6 py-2">
<h2 class="px-2 mb-2 text-lg font-semibold tracking-tight">Workspace</h2>
<div class="space-y-1">
<MenuItem to="/" title="Repos" icon="i-mdi-source-branch" />
<MenuItem to="/settings" title="Settings" icon="i-ion-ios-settings" />
<MenuItem to="https://geprog.com" title="Geprog" icon="i-ion-android-favorite-outline" target="_blank" />
</div>
</div>
<div class="py-2">
<h2 class="relative px-8 text-lg font-semibold tracking-tight">Repos</h2>
<h2 class="relative px-8 text-lg font-semibold tracking-tight">Chats</h2>
<div dir="ltr" class="relative overflow-hidden px-4">
<div data-radix-scroll-area-viewport="" class="h-full w-full rounded-[inherit]">
<div class="p-2 space-y-1">
<div v-for="repo in repos" :key="repo.id">
<MenuItem
:to="`/repos/${repo.id}/chat`"
:title="repo.name"
:img="repo.avatarUrl || undefined"
icon="i-mdi-source-branch"
/>
</div>
<MenuItem to="/chats/add" title="New chat" icon="i-heroicons-plus" />

<MenuItem to="/repos/add" title="Add repo" icon="i-heroicons-plus" />
<div v-for="chat in chats" :key="chat.id">
<MenuItem :to="`/chats/${chat.id}`" :title="chat.name" icon="i-ion-chatbox-ellipses-outline" />
</div>
</div>
</div>
</div>
</div>
<div class="px-6 py-2">
<h2 class="px-2 mb-2 text-lg font-semibold tracking-tight">Workspace</h2>
<div class="space-y-1">
<MenuItem to="/settings" title="Settings" icon="i-ion-ios-settings" />
<MenuItem to="https://geprog.com" title="Geprog" icon="i-ion-android-favorite-outline" target="_blank" />
</div>
</div>
</div>

<div v-if="user" class="absolute inset-x-0 mx-6 bottom-8">
Expand Down Expand Up @@ -74,7 +68,7 @@
<script setup lang="ts">
const { user, logout } = await useAuth();
const colorMode = useColorMode();
const { repos } = await useRepositoriesStore();
const { chats } = await useChatsStore();

const isDark = computed({
get() {
Expand Down
6 changes: 3 additions & 3 deletions components/MenuItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
:to="to"
class="inline-flex items-center text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 duration-200 hover:bg-stone-200 hover:text-stone-900 h-9 rounded-md px-3 justify-start flex-grow gap-2 w-full"
>
<img v-if="img" :src="img" alt="icon" class="w-5 h-5 rounded-md" />
<UIcon v-else-if="icon" :name="icon" class="w-5 h-5" />
<span v-if="title">{{ title }}</span>
<img v-if="img" :src="img" alt="icon" class="w-5 h-5 flex-shrink-0 rounded-md" />
<UIcon v-else-if="icon" :name="icon" class="w-5 h-5 flex-shrink-0" />
<span v-if="title" class="truncate">{{ title }}</span>
</NuxtLink>
</template>

Expand Down
7 changes: 7 additions & 0 deletions composables/chats.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function useChatsStore() {
const { data: chats, refresh } = await useFetch('/api/chats', {
default: () => [],
});

return { chats, refresh };
}
2 changes: 2 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export default defineNuxtConfig({
runtimeConfig: {
ai: {
token: '',
model: 'gpt-3.5-turbo',
// model: 'gpt-4',
},
auth: {
name: 'nuxt-session',
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"db:generate-migration": "drizzle-kit generate:sqlite",
"db:generate": "drizzle-kit generate:sqlite",
"db:push": "drizzle-kit push:sqlite",
"db:explorer": "drizzle-kit studio",
"db:up": "tsx contrib/migrate.ts",
"format:check": "prettier --check .",
"format:fix": "prettier --write .",
"typecheck": "nuxi typecheck",
Expand Down
209 changes: 209 additions & 0 deletions pages/chats/[chat_id].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<template>
<div v-if="repo && chat" class="flex items-center flex-col w-full flex-grow">
<div class="flex w-full p-2 items-center">
<NuxtLink :to="`/repos/${repo.id}`" class="flex gap-4 mr-auto items-center text-2xl">
<img v-if="repo.avatarUrl" :src="repo.avatarUrl" alt="avatar" class="w-8 h-8 rounded-md" />
<span>{{ chat.name }}</span>
</NuxtLink>

<div class="flex gap-2">
<NuxtLink :to="repo.url" target="_blank">
<UButton icon="i-ion-git-pull-request" variant="outline" label="Source" />
</NuxtLink>

<UButton label="Delete" icon="i-heroicons-trash" color="red" @click="deleteChat" />
</div>
</div>

<div class="flex-1 flex w-full max-w-4xl flex-col p-4 gap-4 items-center overflow-y-auto">
<!-- Chat history section -->
<div
v-for="message in chat.messages"
:key="message.id"
class="flex w-full gap-2 items-center"
:class="{
'justify-end': message.from === 'user',
}"
>
<template v-if="message.from === 'user'">
<div class="w-10 flex-shrink-0" />
<UAlert title="" color="primary" variant="subtle">
<template #title>
<Markdown :source="message.content" />
</template>
</UAlert>
<div class="flex items-center justify-center rounded w-10 h-10 p-2 flex-shrink-0">
<span class="text-2xl">💁</span>
</div>
</template>
<template v-else-if="message.from === 'error'">
<div class="flex items-center justify-center w-10 h-10 p-2 flex-shrink-0">
<span class="text-2xl">❌</span>
</div>
<UAlert title="" color="red" variant="subtle">
<template #title>
<Markdown :source="message.content" />
</template>
</UAlert>
<div class="w-10 flex-shrink-0" />
</template>
<template v-else>
<div class="flex items-center justify-center rounded w-10 h-10 p-2 flex-shrink-0">
<span class="text-2xl">🤖</span>
</div>
<UAlert title="" color="primary" variant="subtle">
<template #title>
<Markdown :source="message.content" />
</template>
</UAlert>
<div class="w-10 flex-shrink-0" />
</template>
</div>

<div v-if="thinking" class="flex w-full p-2 gap-2">
<div class="w-10" />
<span class="text-2xl">🤔</span>
<p>Thinking ...</p>
</div>
</div>

<div v-if="chat.messages.length < 2" class="flex gap-4">
<UButton size="lg" label="What is this project about?" @click="askQuestion('What is this project about?')" />
<UButton
size="lg"
label="Which programming languages are used in this project?"
@click="askQuestion('Which programming languages are used in this project?')"
/>
<UButton
size="lg"
label="Could you explain the technical project structure to me?"
@click="askQuestion('Could you explain the technical project structure to me?')"
/>
</div>

<div class="flex my-4 mx-12 w-full max-w-4xl justify-center gap-2">
<div class="flex-grow">
<UInput
v-model="inputText"
color="primary"
variant="outline"
size="lg"
placeholder="Type a message ..."
@keydown.enter="sendMessage"
/>
</div>

<input type="checkbox" id="inputCheck" hidden />

<label for="inputCheck" class="fab-btn flex items-center justify-center cursor-pointer" @click="sendMessage">
<UButton icon="i-mdi-send" size="lg" :ui="{ rounded: 'rounded-full' }" @click="sendMessage" />
</label>
</div>
</div>
</template>

<script lang="ts" setup>
import Markdown from '~/components/Markdown.vue';

const chatsStore = await useChatsStore();

const inputText = ref('');
const thinking = ref(false);
const route = useRoute();
const toast = useToast();
const chatId = computed(() => route.params.chat_id);

const { data: chat, refresh: refreshChat } = await useFetch(() => `/api/chats/${chatId.value}`);
const { data: repo } = await useFetch(() => `/api/repos/${chat.value?.repoId}`);

async function askQuestion(message: string) {
inputText.value = message;
await sendMessage();
}

async function sendMessage() {
if (!chat.value) {
throw new Error('Unexpected: Chat not found');
}

if (thinking.value) {
return;
}

const message = inputText.value;
if (message === '') {
return;
}

chat.value.messages.push({
id: Date.now(),
chatId: chat.value.id,
from: 'user',
content: message,
createdAt: new Date().toISOString(),
});
inputText.value = '';

thinking.value = true;

try {
await $fetch(`/api/chats/${chat.value.id}/chat`, {
method: 'POST',
body: JSON.stringify({
message,
}),
});

await refreshChat();
await chatsStore.refresh();
} catch (e) {
const error = e as Error;
chat.value.messages.push({
id: Date.now(),
chatId: chat.value.id,
from: 'error',
content: error.message,
createdAt: new Date().toISOString(),
});
return;
} finally {
thinking.value = false;
}

await refreshChat();
}

async function deleteChat() {
if (!chat.value) {
return;
}

if (!confirm(`Do you want to remove the chat "${chat.value.name}"`)) {
return;
}

await $fetch(`/api/chats/${chat.value.id}`, {
method: 'DELETE',
});

toast.add({
title: 'Chat removed',
description: `Chat ${chat.value!.name} removed successfully`,
color: 'green',
});

await chatsStore.refresh();

await navigateTo('/');
}
</script>

<style scoped>
.fab-btn {
transition: box-shadow 0.4s ease;
}

input:checked + .fab-btn span {
transform: rotate(360deg);
}
</style>
Loading
Loading