feat: add chat and file management APIs, implement chat loading and saving functionality

- Introduced new API endpoints for chat management including posting and retrieving chat messages.
- Implemented file upload and deletion functionalities for chat and other files.
- Added utility functions for streaming text and loading chat data.
- Created TypeScript types for models and agents used in the application.
- Configured TypeScript settings for server and project.
- Added favicon and workspace configuration for pnpm.
This commit is contained in:
2025-04-14 12:19:30 +02:00
commit c0b5539f12
29 changed files with 12941 additions and 0 deletions

11
app/app.config.ts Normal file
View File

@@ -0,0 +1,11 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'blue',
neutral: 'neutral'
},
container: {
base: 'max-w-4xl'
}
}
})

10
app/app.vue Normal file
View File

@@ -0,0 +1,10 @@
<template>
<NuxtRouteAnnouncer />
<NuxtLoadingIndicator />
<UApp>
<UContainer class="z-50 relative">
<AppHeader />
<NuxtPage class="mt-12 min-h-[calc(100vh-12rem)] max-h-[calc(100vh-12rem)] md:min-h-[calc(100vh-8rem)] md:max-h-[calc(100vh-8rem)]" />
</UContainer>
</UApp>
</template>

7
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,7 @@
@import "tailwindcss";
@import "@nuxt/ui";
body {
font-family: 'DM Sans', sans-serif;
@apply bg-white dark:bg-neutral-900 text-black dark:text-white;
}

View File

@@ -0,0 +1,13 @@
<template>
<div>
</div>
</template>
<script lang="ts" setup>
</script>
<style>
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { AGENTS } from '~~/types'
const colorMode = useColorMode()
const isDark = ref(colorMode.value === 'dark')
watch(isDark, () => {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
})
const agentRoutes = AGENTS.map(item => ({
slug: item.slug,
name: item.name,
icon: item.icon,
to: `/${item.slug}`
}))
async function toggleTheme() {
document.body.style.animation = 'switch-on .5s'
await new Promise(resolve => setTimeout(resolve, 500))
isDark.value = !isDark.value
document.body.style.animation = 'switch-off .5s'
await new Promise(resolve => setTimeout(resolve, 500))
document.body.style.animation = ''
}
const router = useRouter()
defineShortcuts({
t: () => toggleTheme(),
backspace: () => router.back()
})
</script>
<template>
<header class="flex md:items-center justify-between my-8 gap-2">
<NuxtLink
class="text-xl sm:text-3xl text-nowrap gap-2 font-bold duration-300 text-neutral-600 hover:text-black dark:text-neutral-400 dark:hover:text-white"
to="/"
>
Art'Agents
</NuxtLink>
<nav class="flex gap-2 items-center justify-end flex-wrap">
<UTooltip
v-for="agent in agentRoutes"
:key="agent.slug"
:text="agent.name"
:delay-duration="4"
>
<UButton
:icon="agent.icon"
:href="agent.to"
:aria-label="agent.name"
color="neutral"
size="sm"
variant="ghost"
/>
</UTooltip>
<UTooltip
:delay-duration="4"
text="Status page"
>
<UButton
icon="i-ph-warning-duotone"
target="_blank"
href="https://status.arthurdanjou.fr"
color="neutral"
size="sm"
variant="ghost"
/>
</UTooltip>
<USeparator
orientation="vertical"
class="h-6"
/>
<ClientOnly>
<UTooltip
:kbds="['T']"
text="Change theme"
class="cursor-pointer"
:delay-duration="4"
>
<UButton
:icon="isDark ? 'i-ph-moon-duotone' : 'i-ph-sun-duotone'"
color="neutral"
aria-label="switch theme"
size="sm"
variant="ghost"
@click="toggleTheme()"
/>
</UTooltip>
</ClientOnly>
</nav>
</header>
</template>
<style>
@keyframes switch-on {
0% {
filter: blur(0);
transform: scale(1);
}
100% {
transform: scale(0.98);
filter: blur(3px);
}
}
@keyframes switch-off {
0% {
transform: scale(0.98);
filter: blur(3px);
}
100% {
filter: blur(0);
transform: scale(1);
}
}
</style>

36
app/composables/chat.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { Message } from 'ai'
export async function loadChat(slug: string): Promise<Message[]> {
const { blobs } = await $fetch('/api/files')
if (!blobs.find(item => item.pathname === `chats/${slug}.json`)) {
await createChat(slug)
}
const data = await $fetch<string>(`/api/chats/${slug}`)
const dataString = JSON.stringify(data)
if (dataString === '[]') return []
return JSON.parse(dataString)
}
async function createChat(slug: string) {
await $fetch('/api/chats', {
method: 'POST',
body: {
file: {
name: `${slug}.json`,
content: '[]'
}
}
})
}
export async function deleteChat(slug: string) {
await $fetch('/api/files', {
method: 'DELETE',
body: {
pathname: `chats/${slug}.json`
}
})
}

302
app/pages/[agent].vue Normal file
View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue'
import { onStartTyping } from '@vueuse/core'
import type { PROVIDERS } from '~~/types'
import { AGENTS, MODELS } from '~~/types'
const toast = useToast()
const { agent } = useRoute().params
const currentAgent = AGENTS.find(item => item.slug === agent)
if (!currentAgent) {
toast.clear()
toast.add({
title: 'Agent not found',
description: `Please try again`,
color: 'error'
})
}
const model = ref<PROVIDERS>(currentAgent!.defaultModel)
const selectedModel = computed(() => MODELS.find(item => item.model === model.value))
const initialMessages = await loadChat(currentAgent!.slug)
const { messages, input, handleSubmit, status, stop, error, reload } = useChat({
id: currentAgent?.slug,
initialMessages,
sendExtraMessageFields: true,
experimental_prepareRequestBody({ messages, id }) {
return { message: messages[messages.length - 1], id }
},
body: {
model: model.value,
agent: currentAgent
}
})
const inputRef = shallowRef<HTMLInputElement | null>(null)
onStartTyping(() => { // TODO: fix focus
if (inputRef.value !== document.activeElement)
inputRef.value!.focus()
})
const messagesRef = ref<HTMLDivElement | null>(null)
onMounted(() => {
if (messagesRef.value) {
messagesRef.value.scrollTo({ top: messagesRef.value.scrollHeight, behavior: 'smooth' })
}
})
const isModalOpen = ref(false)
async function deleteConversation() {
await deleteChat(currentAgent!.slug)
window.location.reload()
isModalOpen.value = false
toast.add({
title: 'Conversation deleted',
description: `The conversation has been deleted`,
color: 'success'
})
}
</script>
<template>
<main
v-if="currentAgent"
class="flex flex-col justify-between"
>
<div
class="flex justify-between w-full items-center"
>
<UButton
icon="i-heroicons-chevron-left"
color="primary"
variant="ghost"
class="rounded-full"
@click="$router.push({ path: '/' })"
/>
<div class="flex flex-col items-center justify-center mb-4">
<div class="flex items-center justify-center bg-neutral-300 dark:bg-neutral-800 rounded-full p-2">
<UIcon
:name="currentAgent.icon"
size="32"
/>
</div>
<p class="text-2xl">
{{ currentAgent.name }}
</p>
</div>
<UPopover
:content="{
align: 'end',
side: 'bottom'
}"
>
<UButton
icon="i-heroicons-ellipsis-horizontal"
color="primary"
variant="ghost"
class="rounded-full"
/>
<template #content>
<div class="p-2 space-y-2 max-w-94 min-w-48">
<div class="flex items-start gap-2">
<p class="text-sm font-bold">
Name
</p>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
{{ currentAgent.name }}
</p>
</div>
<div class="flex items-start gap-2">
<p class="text-sm font-bold">
Description
</p>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
{{ currentAgent.description }}
</p>
</div>
<div class="flex items-start gap-2">
<p class="text-sm font-bold">
Model
</p>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
{{ currentAgent.defaultModel }}
</p>
</div>
<div class="flex items-start gap-4">
<p class="text-sm font-bold">
Prompt
</p>
<p class="text-sm text-neutral-600 dark:text-neutral-400 text-justify">
{{ currentAgent.prompt }}
</p>
</div>
</div>
</template>
</UPopover>
</div>
<div
ref="messagesRef"
class="h-full overflow-auto"
>
<div v-if="initialMessages.length > 0">
<div
v-for="message in initialMessages"
:key="message.id"
class="flex flex-col py-4"
>
<div :class="message.role === 'assistant' ? 'pr-8 mr-auto' : 'pl-8 ml-auto'">
<MDC
class="p-2 mt-1 text-sm rounded-xl text-smp-2 whitespace-pre-line"
:class="message.role === 'assistant' ? 'text-gray-700 bg-neutral-100 dark:text-white dark:bg-neutral-800' : 'text-white bg-blue-400 dark:text-white dark:bg-blue-400'"
:value="message.content"
:cache-key="message.id.toString()"
/>
</div>
</div>
<USeparator
label="New conversation"
color="neutral"
type="dotted"
/>
</div>
<div class="flex flex-col">
<div class="pr-8 mr-auto py-4">
<MDC
class="p-2 mt-1 text-sm rounded-xl text-smp-2 whitespace-pre-line text-gray-700 bg-neutral-100 dark:text-white dark:bg-neutral-800"
:value="currentAgent?.welcomeMessage || 'Hello, how can I help you today?'"
/>
</div>
</div>
<div
v-for="message in messages.filter(m => !initialMessages.some(im => im.id === m.id))"
:key="message.id"
class="flex flex-col py-4"
>
<div :class="message.role === 'assistant' ? 'pr-8 mr-auto' : 'pl-8 ml-auto'">
<MDC
class="p-2 mt-1 text-sm rounded-xl text-smp-2 whitespace-pre-line"
:class="message.role === 'assistant' ? 'text-gray-700 bg-neutral-100 dark:text-white dark:bg-neutral-800' : 'text-white bg-blue-400 dark:text-white dark:bg-blue-400'"
:value="message.content"
:cache-key="message.id"
/>
</div>
</div>
</div>
<div
v-if="error"
class="flex items-center justify-center gap-2"
>
<div class="text-red-500">
{{ 'An error occurred' }}
</div>
<UButton
color="neutral"
variant="subtle"
size="xs"
@click="reload()"
>
retry
</UButton>
</div>
<ClientOnly>
<form
class="mt-4 shadow-md flex flex-col items-center w-full p-2 gap-2 bg-zinc-100 rounded-2xl dark:bg-neutral-800"
@submit.prevent="handleSubmit"
@keydown.enter.prevent="handleSubmit"
>
<div class="w-full flex gap-2">
<UTextarea
ref="inputRef"
v-model="input"
placeholder="Type what you want to ask..."
class="w-full min-h-8"
:ui="{
base: 'min-h-8'
}"
color="primary"
autoresize
:rows="1"
variant="soft"
:disabled="Boolean(error)"
/>
<div class="flex items-start">
<UButton
v-if="status !== 'ready'"
icon="i-heroicons-stop"
color="primary"
@click="stop"
/>
<UButton
v-else
icon="i-heroicons-arrow-long-up-16-solid"
type="submit"
class="rounded-xl duration-300"
:color="input.length === 0 ? 'neutral' : 'primary'"
:disabled="input.length === 0"
/>
</div>
</div>
<div class="w-full flex items-center gap-2 px-2">
<UButton
icon="i-heroicons-paper-clip"
color="neutral"
variant="subtle"
class="rounded-full"
@click="stop"
/>
<UTooltip text="Select model">
<USelect
v-model="model"
class="rounded-full w-40"
color="neutral"
variant="subtle"
:items="MODELS"
label-key="name"
value-key="model"
:icon="selectedModel?.icon"
trailing-icon=""
leading-icon=""
/>
</UTooltip>
<UModal
v-model:open="isModalOpen"
title="Are you sure you want to delete this conversation?"
description="This action cannot be undone. The agent will lost all the conversation history and context."
:ui="{ footer: 'justify-end' }"
>
<UTooltip text="Clear conversation">
<UButton
:disabled="messages.length === 0"
icon="i-heroicons-trash"
color="neutral"
variant="subtle"
class="rounded-full"
/>
</UTooltip>
<template #footer>
<div class="flex gap-2">
<UButton
color="neutral"
label="Dismiss"
@click.prevent="isModalOpen = false"
/>
<UButton
color="error"
label="Delete"
@click.prevent="deleteConversation"
/>
</div>
</template>
</UModal>
</div>
</form>
</ClientOnly>
</main>
<main v-else>
<div class="flex items-center justify-center h-full">
Agent not found
</div>
</main>
</template>

68
app/pages/index.vue Normal file
View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { AGENTS, MODELS } from '~~/types'
</script>
<template>
<main class="flex flex-col gap-16">
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">
Choose an agent
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="agent in AGENTS"
:key="agent.slug"
>
<NuxtLink
:to="`/${agent.slug}`"
class="flex items-start gap-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg p-4 duration-300"
>
<div class="flex items-center justify-center bg-neutral-300 dark:bg-neutral-800 rounded-full p-2">
<UIcon
:name="agent.icon"
size="24"
/>
</div>
<div class="flex flex-col">
<h2 class="text-lg font-bold">
{{ agent.name }}
</h2>
<p>
{{ agent.description }}
</p>
<p>
{{ agent.defaultModel }}
</p>
<p>
{{ agent.prompt }}
</p>
</div>
</NuxtLink>
</div>
</div>
</div>
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-bold">
Available models
</h1>
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
<div
v-for="model in MODELS"
:key="model.name"
>
<div class="flex gap-4 items-center">
<div class="flex items-center justify-center bg-neutral-300 dark:bg-neutral-800 rounded-full p-2">
<UIcon
:name="model.icon"
size="24"
/>
</div>
<p>
{{ model.name }}
</p>
</div>
</div>
</div>
</div>
</main>
</template>