feat: integrate new AI tools and enhance API functionality

- Added '@nuxtjs/mdc' module to nuxt.config.ts for improved UI components.
- Configured MDC settings to disable anchor links for headings.
- Updated API proxy settings to include '/api/uses' and a general '/api/' endpoint.
- Introduced new dependencies for AI SDKs and tools in package.json.
- Created a new chat API endpoint to handle AI interactions with various tools.
- Implemented utility functions for activity tracking, resource reading, resume retrieval, statistics, status monitoring, and categorized tool usage.
- Updated social links in types/index.ts to redirect through a custom domain.
- Updated worker configuration to include AI binding for Cloudflare.
This commit is contained in:
2025-12-19 17:57:43 +01:00
parent 929899722b
commit 6e28fdd17a
16 changed files with 717 additions and 328 deletions

View File

@@ -4,8 +4,6 @@
:root {
--animate-wave: wave 3s infinite;
--ui-black: #000000;
--ui-white: #ffffff;
--ui-bg: #f8f8f8;
--ui-font-family: 'DM Sans', sans-serif;
@@ -15,9 +13,7 @@
.dark {
--animate-wave: wave 3s infinite;
--ui-black: #000000;
--ui-white: #ffffff;
--ui-bg: #141414;
--ui-bg: #0f0f0f;
--ui-font-family: 'DM Sans', sans-serif;
transition-duration: 0.7s;

View File

@@ -1,25 +0,0 @@
<script lang="ts" setup>
defineProps({
title: {
type: String,
required: true
},
description: {
type: String,
required: true
}
})
</script>
<template>
<div>
<h1
class="text-3xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100"
>
{{ title }}
</h1>
<p class="mt-4 text-base">
{{ description }}
</p>
</div>
</template>

View File

@@ -1,13 +1,148 @@
<script lang="ts" setup>
import { getTextFromMessage } from '@nuxt/ui/utils/ai'
import { DefaultChatTransport } from 'ai'
import { Chat } from '@ai-sdk/vue'
const toast = useToast()
const input = ref('')
const { t, locale } = useI18n({ useScope: 'local' })
const chat = new Chat({
transport: new DefaultChatTransport({
api: '/api/chat',
body: () => ({
messages: chat.messages,
lang: locale.value
})
}),
onError(error) {
toast.add({
title: 'Error',
description: error.message,
color: 'red'
})
}
})
function handleSubmit(e: Event) {
e.preventDefault()
if (input.value.trim()) {
chat.sendMessage({ text: input.value })
input.value = ''
}
}
const clipboard = useClipboard()
const actions = ref([
{
label: 'Copy to clipboard',
icon: 'i-lucide-copy',
onClick: (message: never) => clipboard.copy(getTextFromMessage(message))
}
])
</script>
<template>
<div>
CHAT
</div>
<main>
<UDashboardPanel
:ui="{ body: 'p-0 sm:p-0' }"
class="max-h-[calc(100vh-28rem)]! min-h-[calc(100vh-28rem)]! border-none"
:resizable="false"
>
<template #body>
<UContainer>
<UChatMessages
:actions
:messages="chat.messages"
:status="chat.status"
:user="{
variant: 'solid'
}"
:assistant="{
variant: 'naked'
}"
:auto-scroll="{
color: 'neutral',
variant: 'outline'
}"
>
<template #indicator>
<UButton
class="px-0"
color="neutral"
variant="link"
loading
loading-icon="i-lucide-loader"
label="Thinking..."
/>
</template>
<template #content="{ message }">
<MDC
:value="getTextFromMessage(message)"
:cache-key="message.id"
class="*:first:mt-0 *:last:mb-0"
/>
</template>
</UChatMessages>
</UContainer>
</template>
<template #footer>
<UContainer>
<ClientOnly>
<UCard
variant="outline"
class="rounded-xl"
:ui="{ body: 'p-2 sm:p-2' }"
>
<UChatPrompt
v-model="input"
:placeholder="t('placeholder')"
:error="chat.error"
@submit="handleSubmit"
>
<UChatPromptSubmit
:status="chat.status"
color="neutral"
submitted-color="neutral"
submitted-variant="subtle"
submitted-icon="i-lucide-square"
streaming-color="neutral"
streaming-variant="subtle"
streaming-icon="i-lucide-square"
error-color="red"
error-variant="soft"
error-icon="i-lucide-rotate-ccw"
@stop="chat.stop()"
@reload="chat.regenerate()"
/>
</UChatPrompt>
</UCard>
</ClientOnly>
</UContainer>
</template>
</UDashboardPanel>
</main>
</template>
<style scoped>
</style>
<i18n lang="json">
{
"en": {
"placeholder": "Type your message...",
"thinking": "Thinking..."
},
"fr": {
"placeholder": "Tapez votre message...",
"thinking": "Réflexion..."
},
"es": {
"placeholder": "Escribe tu mensaje...",
"thinking": "Pensando..."
}
}
</i18n>

646
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ export default defineNuxtConfig({
modules: [
'@nuxt/ui',
'@nuxtjs/mdc',
'@nuxt/content',
'@nuxthub/core',
'@nuxt/eslint',
@@ -33,6 +34,12 @@ export default defineNuxtConfig({
fallback: 'light'
},
mdc: {
headings: {
anchorLinks: false
}
},
ui: {
theme: {
colors: [
@@ -72,6 +79,12 @@ export default defineNuxtConfig({
},
'/api/stats': {
proxy: `${process.env.NUXT_API_URL}/api/stats`
},
'/api/uses': {
proxy: `${process.env.NUXT_API_URL}/api/uses`
},
'/api/': {
proxy: `${process.env.NUXT_API_URL}/api/`
}
},

View File

@@ -11,20 +11,26 @@
"cf-typegen": "wrangler types"
},
"dependencies": {
"@ai-sdk/mcp": "^0.0.12",
"@ai-sdk/vue": "^2.0.115",
"@libsql/client": "^0.15.15",
"@modelcontextprotocol/sdk": "^1.25.1",
"@nuxt/content": "3.9.0",
"@nuxt/eslint": "1.12.1",
"@nuxt/ui": "4.3.0",
"@nuxthub/core": "0.10.3",
"@nuxtjs/i18n": "10.2.1",
"@nuxt/ui": "^4.3.0",
"@nuxthub/core": "^0.10.3",
"@nuxtjs/mdc": "^0.19.1",
"@vueuse/core": "^14.1.0",
"@vueuse/math": "^14.1.0",
"ai": "5.0.115",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"nuxt": "4.2.2",
"nuxt-studio": "1.0.0-alpha.4",
"vue": "3.5.25",
"vue": "3.5.26",
"vue-router": "4.6.4",
"zod": "4.2.1"
"workers-ai-provider": "^2.0.0",
"zod": "^4.2.1"
},
"devDependencies": {
"@iconify-json/devicon": "1.2.53",

89
server/api/chat.post.ts Normal file
View File

@@ -0,0 +1,89 @@
import { defineEventHandler, readValidatedBody } from 'h3'
import { z } from 'zod'
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
streamText
} from 'ai'
import type { UIMessage } from 'ai'
import { createWorkersAI } from 'workers-ai-provider'
import { statsTool } from '~~/shared/utils/tools/stats'
import { activityTool } from '~~/shared/utils/tools/activity'
import { resumesTool } from '~~/shared/utils/tools/read-resume'
import { resourceTool } from '~~/shared/utils/tools/read-resources'
import { statusPageTool } from '~~/shared/utils/tools/status-page'
import { usesByCategoryTool } from '~~/shared/utils/tools/uses-by-category'
export default defineEventHandler(async (event) => {
const { messages, lang } = await readValidatedBody(event, z.object({
messages: z.array(z.custom<UIMessage>()),
lang: z.string().optional()
}).parse)
const { AI } = event.context.cloudflare.env || {}
if (!AI) {
throw createError({
statusCode: 500,
statusMessage: 'Cloudflare AI Binding not found. Check wrangler.json.'
})
}
const validCategories = ['contact', 'education', 'experiences', 'hobbies', 'languages', 'profile', 'projects', 'skills', 'uses'].join(', ')
const workersAI = createWorkersAI({ binding: AI })
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const result = streamText({
model: workersAI('@cf/meta/llama-3-8b-instruct'),
system: `You are Arthur Danjou's personal portfolio assistant (Data Science Student).
### TOOL USAGE GUIDE - FOLLOW STRICTLY:
1. **readResources(category)**: YOUR PRIMARY BRAIN.
- Use this for ANY question about Arthur's life, background, or work.
- Categories: ${validCategories}.
- Example: "What are his skills?" -> call readResources('skills').
- Example: "Where did he study?" -> call readResources('education').
2. **readResume()**: THE FILE DISPENSER.
- **WARNING:** This tool ONLY returns a direct DOWNLOAD LINK (URL). It does NOT contain text.
- **WHEN TO USE:** ONLY when the user explicitly asks to "download", "get", "see", or "have" the CV/Resume file.
- **DO NOT USE:** If the user asks "What is on his resume?" or "Describe his experience", DO NOT use this. Use 'readResources' instead.
3. **stats()**: GITHUB METRICS.
- Use this for questions about coding volume, commit streaks, languages used percentages, or GitHub activity.
4. **activity()**: LIVE STATUS.
- Use this to know what Arthur is doing RIGHT NOW (e.g., "Is he coding?", "What is he listening to on Spotify?").
5. **statusPage()**: INFRASTRUCTURE HEALTH.
- Use this if the user asks about the website's uptime, server status, or if services are down.
6. **usesByCategory()**: TECH STACK & GEAR.
- Use this for specific questions about his hardware (Mac vs PC), software, developer tools, or desk setup.
### GENERAL RULES:
- If you don't call a tool, you know NOTHING. Do not hallucinate.
- Always answer in ${lang || 'English'}.
- Be concise and professional.
`,
tools: {
stats: statsTool,
activity: activityTool,
readResume: resumesTool,
readResources: resourceTool,
statusPage: statusPageTool,
usesByCategory: usesByCategoryTool
},
messages: await convertToModelMessages(messages)
})
writer.merge(result.toUIMessageStream())
}
})
return createUIMessageStreamResponse({ stream })
})

6
shared/utils/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './tools/activity'
export * from './tools/read-resources'
export * from './tools/read-resume'
export * from './tools/stats'
export * from './tools/status-page'
export * from './tools/uses-by-category'

View File

@@ -0,0 +1,12 @@
import type { UIToolInvocation } from 'ai'
import { tool } from 'ai'
import type { Activity } from '~~/types'
export type ActivityUIToolInvocation = UIToolInvocation<typeof activityTool>
export const activityTool = tool({
description: 'Real-time current activity and status of Arthur Danjou, including what he\'s currently working on',
execute: async () => {
return await $fetch<Activity>('/api/activity')
}
})

View File

@@ -0,0 +1,25 @@
import type { UIToolInvocation } from 'ai'
import { tool } from 'ai'
import { z } from 'zod'
export type resourceUIToolInvocation = UIToolInvocation<typeof resourceTool>
export const resourceTool = tool({
description: 'Read a resource from the server API',
inputSchema: z.object({
resource: z.enum([
'contact',
'education',
'experiences',
'hobbies',
'languages',
'profile',
'projects',
'skills',
'uses'
]).describe('resource name')
}),
execute: async ({ resource }) => {
return await $fetch(`/api/${resource}`)
}
})

View File

@@ -0,0 +1,15 @@
import type { UIToolInvocation } from 'ai'
import { tool } from 'ai'
import { z } from 'zod'
export type ResumesUIToolInvocation = UIToolInvocation<typeof resumesTool>
export const resumesTool = tool({
description: 'Retrieves a direct download link to Arthur Danjou\'s professional resume in the specified language. Supports both English and French versions.',
inputSchema: z.object({
lang: z.enum(['en', 'fr']).describe('The language for the resume: \'en\' for English or \'fr\' for French.')
}),
execute: async ({ lang }) => {
return `/api/resumes/${lang}`
}
})

View File

@@ -0,0 +1,12 @@
import type { UIToolInvocation } from 'ai'
import { tool } from 'ai'
import type { Stats } from '~~/types'
export type StatsUIToolInvocation = UIToolInvocation<typeof statsTool>
export const statsTool = tool({
description: 'Detailed coding statistics and analytics from WakaTime, including programming languages, time spent coding, and productivity metrics',
execute: async () => {
return await $fetch<Stats>('/api/stats')
}
})

View File

@@ -0,0 +1,11 @@
import type { UIToolInvocation } from 'ai'
import { tool } from 'ai'
export type StatusPageUIToolInvocation = UIToolInvocation<typeof statusPageTool>
export const statusPageTool = tool({
description: 'Real-time status, uptime monitoring, and incident reports for Arthur Danjou\'s homelab infrastructure, powered by UptimeKuma',
execute: async () => {
return await $fetch('/api/status-page')
}
})

View File

@@ -0,0 +1,17 @@
import type { UIToolInvocation } from 'ai'
import { tool } from 'ai'
import { z } from 'zod'
export type UsesByCategoryUIToolInvocation = UIToolInvocation<typeof usesByCategoryTool>
export const usesByCategoryTool = tool({
description: 'Retrieves a filtered list of tools, software, and hardware used by Arthur Danjou based on a specific category. Available categories: homelab, IDE, hardware, and software.',
inputSchema: z.object({
categoryName: z.enum(['homelab', 'ide', 'hardware', 'software']).describe('The category to filter by: \'homelab\', \'ide\', \'hardware\', or \'software\'.')
}),
execute: async ({ categoryName }: { categoryName: 'homelab' | 'ide' | 'hardware' | 'software' }) => {
const uses = await $fetch<{ category: string }[]>('/api/uses')
return uses.filter(use => use.category === categoryName)
}
})

View File

@@ -124,10 +124,10 @@ export const activityMessages: Record<'en' | 'fr' | 'es', ActivityMessages> = {
}
export const socials = [
{ icon: 'i-ph-x-logo-duotone', label: 'Twitter', to: 'https://twitter.com/ArthurDanj' },
{ icon: 'i-ph-github-logo-duotone', label: 'GitHub', to: 'https://github.com/ArthurDanjou' },
{ icon: 'i-ph-linkedin-logo-duotone', label: 'LinkedIn', to: 'https://www.linkedin.com/in/arthurdanjou/' },
{ icon: 'i-ph-discord-logo-duotone', label: 'Discord', to: 'https://discordapp.com/users/179635349100691456' }
{ icon: 'i-ph-x-logo-duotone', label: 'Twitter', to: 'https://go.arthurdanjou.fr/twitter' },
{ icon: 'i-ph-github-logo-duotone', label: 'GitHub', to: 'https://go.arthurdanjou.fr/github' },
{ icon: 'i-ph-linkedin-logo-duotone', label: 'LinkedIn', to: 'https://go.arthurdanjou.fr/linkedin' },
{ icon: 'i-ph-discord-logo-duotone', label: 'Discord', to: 'https://go.arthurdanjou.fr/discord' }
] as const
type Locale = 'en' | 'fr' | 'es'

View File

@@ -1,5 +1,5 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 9ebceff030e512b05e46edbb174bea44)
// Generated by Wrangler by running `wrangler types` (hash: 7333ef017a5016b51354dce96a893062)
// Runtime types generated with workerd@1.20251217.0 2025-12-13
declare namespace Cloudflare {
interface Env {
@@ -9,6 +9,7 @@ declare namespace Cloudflare {
STUDIO_GITHUB_CLIENT_ID: string;
STUDIO_GITHUB_CLIENT_SECRET: string;
DB: D1Database;
AI: Ai;
}
}
interface Env extends Cloudflare.Env {}