mirror of
https://github.com/ArthurDanjou/artagents.git
synced 2026-01-14 04:04:32 +01:00
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:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.wrangler
|
||||
dist
|
||||
.drizzle
|
||||
.vscode
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Hello Edge
|
||||
|
||||
A minimal [Nuxt](https://nuxt.com) starter deployed on the Edge using [NuxtHub](https://hub.nuxt.com).
|
||||
|
||||
https://hello.nuxt.dev
|
||||
|
||||
<a href="https://hello.nuxt.dev">
|
||||
<img src="https://github.com/nuxt-hub/hello-edge/assets/904724/99d1bd54-ef7e-4ac9-83ad-0a290f85edcf" alt="Hello World template for NuxtHub" />
|
||||
</a>
|
||||
|
||||
## Features
|
||||
|
||||
- Server-Side rendering on Cloudflare Workers
|
||||
- ESLint setup
|
||||
- Ready to add a database, blob and KV storage
|
||||
- One click deploy on 275+ locations for free
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies with [pnpm](https://pnpm.io/installation#using-corepack):
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
You can update the main text displayed by creating a `.env`:
|
||||
|
||||
```bash
|
||||
NUXT_PUBLIC_HELLO_TEXT="Hello my world!"
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
|
||||
Deploy the application on the Edge with [NuxtHub](https://hub.nuxt.com) on your Cloudflare account:
|
||||
|
||||
```bash
|
||||
npx nuxthub deploy
|
||||
```
|
||||
|
||||
Then checkout your server logs, analaytics and more in the [NuxtHub Admin](https://admin.hub.nuxt.com).
|
||||
|
||||
You can also deploy using [Cloudflare Pages CI](https://hub.nuxt.com/docs/getting-started/deploy#cloudflare-pages-ci).
|
||||
|
||||
11
app/app.config.ts
Normal file
11
app/app.config.ts
Normal 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
10
app/app.vue
Normal 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
7
app/assets/css/main.css
Normal 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;
|
||||
}
|
||||
13
app/components/AppFooter.vue
Normal file
13
app/components/AppFooter.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
119
app/components/AppHeader.vue
Normal file
119
app/components/AppHeader.vue
Normal 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
36
app/composables/chat.ts
Normal 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
302
app/pages/[agent].vue
Normal 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
68
app/pages/index.vue
Normal 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>
|
||||
6
eslint.config.mjs
Normal file
6
eslint.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
40
nuxt.config.ts
Normal file
40
nuxt.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@nuxthub/core',
|
||||
'@nuxt/eslint',
|
||||
'@nuxt/ui',
|
||||
'@nuxt/icon',
|
||||
'@nuxtjs/mdc'
|
||||
],
|
||||
|
||||
devtools: { enabled: true },
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
runtimeConfig: {
|
||||
cloudflare: {
|
||||
email: '',
|
||||
apiKey: '',
|
||||
accountId: ''
|
||||
}
|
||||
},
|
||||
|
||||
future: { compatibilityVersion: 4 },
|
||||
|
||||
compatibilityDate: '2025-03-01',
|
||||
|
||||
hub: {
|
||||
ai: true,
|
||||
cache: true,
|
||||
blob: true
|
||||
},
|
||||
|
||||
eslint: {
|
||||
config: {
|
||||
stylistic: {
|
||||
quotes: 'single',
|
||||
commaDangle: 'never'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "npx nuxthub preview",
|
||||
"deploy": "npx nuxthub deploy",
|
||||
"postinstall": "nuxt prepare",
|
||||
"lint": "eslint .",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/vue": "^1.2.8",
|
||||
"@nuxt/eslint": "^1.3.0",
|
||||
"@nuxt/icon": "1.12.0",
|
||||
"@nuxt/ui": "3.0.2",
|
||||
"@nuxthub/core": "^0.8.24",
|
||||
"@nuxtjs/mdc": "0.16.1",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"ai": "^4.3.6",
|
||||
"h3-zod": "^0.5.3",
|
||||
"nuxt": "^3.16.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"workers-ai-provider": "^0.3.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/heroicons": "^1.2.2",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@nuxt/eslint-config": "^1.3.0",
|
||||
"eslint": "^9.24.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vue-tsc": "^2.2.8",
|
||||
"wrangler": "^4.10.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.8.0"
|
||||
}
|
||||
11923
pnpm-lock.yaml
generated
Normal file
11923
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- esbuild
|
||||
- sharp
|
||||
- vue-demi
|
||||
- workerd
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
55
server/api/chat.post.ts
Normal file
55
server/api/chat.post.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Message } from 'ai'
|
||||
import { appendResponseMessages, appendClientMessage, streamText } from 'ai'
|
||||
import { createWorkersAI } from 'workers-ai-provider'
|
||||
import { loadChat } from '~~/server/utils/chat'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { id, message, model, agent } = await readBody(event)
|
||||
|
||||
console.log(model, agent)
|
||||
|
||||
const workersAI = createWorkersAI({ binding: hubAI() })
|
||||
|
||||
const previousMessages = await loadChat(id)
|
||||
|
||||
const messages = appendClientMessage({
|
||||
messages: previousMessages,
|
||||
message
|
||||
})
|
||||
|
||||
const result = streamText({
|
||||
model: workersAI('@cf/meta/llama-3.1-8b-instruct'),
|
||||
// system, TODO: add system
|
||||
// prompt, TODO: add prompt
|
||||
messages,
|
||||
// tools, TODO: add tools
|
||||
async onFinish({ response }) {
|
||||
await saveChat({
|
||||
id,
|
||||
messages: appendResponseMessages({
|
||||
messages,
|
||||
responseMessages: response.messages
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
result.consumeStream()
|
||||
|
||||
return result.toDataStreamResponse()
|
||||
})
|
||||
|
||||
async function saveChat({ id, messages }: { id: string, messages: Message[] }) {
|
||||
const hub = hubBlob()
|
||||
|
||||
await hub.delete(`chats/${id}.json`)
|
||||
await $fetch('/api/chats', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
file: {
|
||||
name: `${id}.json`,
|
||||
content: JSON.stringify(messages, null, 2)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
27
server/api/chats/[...pathname].get.ts
Normal file
27
server/api/chats/[...pathname].get.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { streamToText } from '~~/server/utils/stream'
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const { pathname } = getRouterParams(event)
|
||||
|
||||
try {
|
||||
const stream = await hubBlob().serve(event, `chats/${pathname}.json`)
|
||||
|
||||
if (!(stream instanceof ReadableStream)) {
|
||||
throw new Error('Le stream n\'est pas valide')
|
||||
}
|
||||
|
||||
const read = await streamToText(stream)
|
||||
|
||||
try {
|
||||
return JSON.parse(read)
|
||||
}
|
||||
catch (jsonError) {
|
||||
console.error('Erreur lors du parsing JSON:', jsonError)
|
||||
return { error: 'Erreur de format JSON' }
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Erreur lors du traitement du stream:', error)
|
||||
return { error: 'Erreur serveur' }
|
||||
}
|
||||
})
|
||||
18
server/api/chats/index.post.ts
Normal file
18
server/api/chats/index.post.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default eventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const file = new File([body.file.content], body.file.name, { type: 'application/json' })
|
||||
|
||||
if (!file || !file.size) {
|
||||
throw createError({ statusCode: 400, message: 'No file provided' })
|
||||
}
|
||||
|
||||
ensureBlob(file, {
|
||||
maxSize: '1MB',
|
||||
types: ['application/jsonml+json', 'application/json']
|
||||
})
|
||||
|
||||
return hubBlob().put(file.name, file, {
|
||||
addRandomSuffix: false,
|
||||
prefix: 'chats'
|
||||
})
|
||||
})
|
||||
12
server/api/files/index.delete.ts
Normal file
12
server/api/files/index.delete.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
import { useValidatedBody } from 'h3-zod'
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const { pathname } = await useValidatedBody(event, {
|
||||
pathname: z.string()
|
||||
})
|
||||
|
||||
await hubBlob().del(pathname)
|
||||
|
||||
return sendNoContent(event)
|
||||
})
|
||||
3
server/api/files/index.get.ts
Normal file
3
server/api/files/index.get.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default defineEventHandler(() => {
|
||||
return hubBlob().list()
|
||||
})
|
||||
18
server/api/files/index.post.ts
Normal file
18
server/api/files/index.post.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default eventHandler(async (event) => {
|
||||
const form = await readFormData(event)
|
||||
const file = form.get('file') as File
|
||||
|
||||
if (!file || !file.size) {
|
||||
throw createError({ statusCode: 400, message: 'No file provided' })
|
||||
}
|
||||
|
||||
ensureBlob(file, {
|
||||
maxSize: '1MB',
|
||||
types: ['image', 'pdf', 'application/pdf']
|
||||
})
|
||||
|
||||
return hubBlob().put(file.name, file, {
|
||||
addRandomSuffix: false,
|
||||
prefix: 'files'
|
||||
})
|
||||
})
|
||||
5
server/routes/files/[...pathname].get.ts
Normal file
5
server/routes/files/[...pathname].get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default eventHandler(async (event) => {
|
||||
const { pathname } = getRouterParams(event)
|
||||
|
||||
return hubBlob().serve(event, pathname)
|
||||
})
|
||||
3
server/tsconfig.json
Normal file
3
server/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
33
server/utils/chat.ts
Normal file
33
server/utils/chat.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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/chats/${slug}.json`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
13
server/utils/stream.ts
Normal file
13
server/utils/stream.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export async function streamToText(stream: ReadableStream<Uint8Array>) {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let result = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
result += decoder.decode(value, { stream: true })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
79
types/index.ts
Normal file
79
types/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export interface Model {
|
||||
name: string
|
||||
model: PROVIDERS
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
slug: string
|
||||
name: string
|
||||
prompt: string
|
||||
welcomeMessage: string
|
||||
icon: string
|
||||
description: string
|
||||
defaultModel: PROVIDERS
|
||||
}
|
||||
|
||||
export enum PROVIDERS {
|
||||
META = '@cf/meta/llama-3.1-8b-instruct',
|
||||
MISTRAL = '@cf/mistral/mistral-7b-instruct-v0.1',
|
||||
GOOGLE = '@cf/google/gemma-3-12b-it',
|
||||
QWEN = '@cf/qwen/qwen2.5-coder-32b-instruct'
|
||||
}
|
||||
|
||||
export const AGENTS: Agent[] = [
|
||||
{
|
||||
slug: 'agent-1',
|
||||
name: 'Agent 1',
|
||||
prompt: 'You are a helpful assistant.',
|
||||
welcomeMessage: 'Hello, how can I help you today?',
|
||||
icon: 'i-heroicons-sparkles',
|
||||
description: 'This is a description',
|
||||
defaultModel: PROVIDERS.MISTRAL
|
||||
},
|
||||
{
|
||||
slug: 'agent-2',
|
||||
name: 'Agent 2',
|
||||
prompt: 'You are a helpful assistant.',
|
||||
welcomeMessage: 'Hello, how can I help you today?',
|
||||
icon: 'i-heroicons-sparkles',
|
||||
description: 'This is a description',
|
||||
defaultModel: PROVIDERS.META
|
||||
},
|
||||
{
|
||||
slug: 'agent-3',
|
||||
name: 'Agent 3',
|
||||
prompt: 'You are a helpful assistant.',
|
||||
welcomeMessage: 'Hello, how can I help you today?',
|
||||
icon: 'i-heroicons-sparkles',
|
||||
description: 'This is a description',
|
||||
defaultModel: PROVIDERS.GOOGLE
|
||||
},
|
||||
{
|
||||
slug: 'agent-4',
|
||||
name: 'Agent 4',
|
||||
prompt: 'You are a helpful assistant.',
|
||||
welcomeMessage: 'Hello, how can I help you today?',
|
||||
icon: 'i-heroicons-sparkles',
|
||||
description: 'This is a description',
|
||||
defaultModel: PROVIDERS.QWEN
|
||||
}
|
||||
]
|
||||
|
||||
export const MODELS: Array<Model> = [
|
||||
{
|
||||
name: 'Mistral Large',
|
||||
model: PROVIDERS.MISTRAL,
|
||||
icon: 'i-logos-mistral-ai-icon'
|
||||
},
|
||||
{
|
||||
name: 'Meta Llama',
|
||||
model: PROVIDERS.META,
|
||||
icon: 'i-logos-meta-icon'
|
||||
},
|
||||
{
|
||||
name: 'Google Gemini',
|
||||
model: PROVIDERS.GOOGLE,
|
||||
icon: 'i-logos-google-icon'
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user