Vue+Tailwind Coding guidelines
<script setup>
syntax for all components/src
/assets
/components
/ui # Reusable UI components
/layouts # Layout components
/features # Feature-specific components
/composables # Custom composables
/stores # Pinia stores
/services # API services
/utils # Utility functions
/types # TypeScript type definitions
/views # Page components
<template>
<div class="rounded-lg p-4 shadow-md bg-white dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{{ title }}</h3>
<button v-if="dismissable"
@click="$emit('dismiss')"
class="text-gray-400 hover:text-gray-500 transition-colors">
<span class="sr-only">Dismiss</span>
<XMarkIcon class="h-5 w-5" />
</button>
</div>
<div class="text-gray-700 dark:text-gray-300">
<slot></slot>
</div>
<div v-if="$slots.footer" class="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { XMarkIcon } from '@heroicons/vue/24/outline'
interface Props {
title: string
dismissable?: boolean
}
defineProps<Props>()
defineEmits<{
(e: 'dismiss'): void
}>()
</script>
// stores/aiAssistant.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Message, AssistantState } from '@/types'
import { fetchCompletion } from '@/services/ai'
export const useAIAssistantStore = defineStore('aiAssistant', () => {
// State
const messages = ref<Message[]>([])
const isProcessing = ref(false)
const error = ref<string | null>(null)
// Getters
const conversationHistory = computed(() => messages.value)
const lastMessage = computed(() =>
messages.value.length > 0 ? messages.value[messages.value.length - 1] : null
)
// Actions
async function sendMessage(content: string) {
try {
isProcessing.value = true
error.value = null
// Add user message
messages.value.push({
role: 'user',
content,
timestamp: new Date()
})
// Get AI response
const response = await fetchCompletion(messages.value)
// Add AI response
messages.value.push({
role: 'assistant',
content: response.content,
timestamp: new Date()
})
} catch (err) {
error.value = err instanceof Error ? err.message : 'An unknown error occurred'
} finally {
isProcessing.value = false
}
}
function clearConversation() {
messages.value = []
}
return {
// State
messages,
isProcessing,
error,
// Getters
conversationHistory,
lastMessage,
// Actions
sendMessage,
clearConversation
}
})
// services/ai.ts
import axios from 'axios'
import type { Message } from '@/types'
const API_URL = import.meta.env.VITE_AI_API_URL
const API_KEY = import.meta.env.VITE_AI_API_KEY
export async function fetchCompletion(messages: Message[]) {
try {
const response = await axios.post(`${API_URL}/completions`, {
messages: messages.map(({ role, content }) => ({ role, content })),
temperature: 0.7,
max_tokens: 1000,
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
}
})
return response.data
} catch (error) {
console.error('AI completion error:', error)
throw new Error('Failed to get AI response')
}
}
<template>
<form @submit="onSubmit" class="space-y-4">
<div>
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Your message
</label>
<textarea
id="message"
v-model="message"
:disabled="isSubmitting"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white"
rows="4"
placeholder="Type your message here..."
></textarea>
<p v-if="errors.message" class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ errors.message }}
</p>
</div>
<div class="flex justify-end">
<button
type="submit"
:disabled="isSubmitting"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isSubmitting">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Sending...
</span>
<span v-else>Send</span>
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useForm } from 'vee-validate'
import { z } from 'zod'
import { toFormValidator } from '@vee-validate/zod'
import { useAIAssistantStore } from '@/stores/aiAssistant'
const aiStore = useAIAssistantStore()
// Define validation schema with Zod
const validationSchema = toFormValidator(
z.object({
message: z.string().min(1, 'Please enter a message').max(1000, 'Message is too long')
})
)
// Use VeeValidate's useForm
const { handleSubmit, errors } = useForm({
validationSchema
})
const message = ref('')
const isSubmitting = ref(false)
// Form submission handler
const onSubmit = handleSubmit(async (values) => {
try {
isSubmitting.value = true
await aiStore.sendMessage(message.value)
message.value = '' // Clear input after successful submission
} catch (error) {
console.error('Error sending message:', error)
} finally {
isSubmitting.value = false
}
})
</script>
// composables/useAICompletion.ts
import { useQuery, useMutation } from '@tanstack/vue-query'
import { fetchCompletion } from '@/services/ai'
import type { Message } from '@/types'
export function useAICompletion() {
const messages = ref<Message[]>([])
// Mutation for sending messages
const sendMessageMutation = useMutation({
mutationFn: (content: string) => {
const newMessages = [...messages.value, {
role: 'user',
content,
timestamp: new Date()
}]
return fetchCompletion(newMessages)
},
onSuccess: (data, variables) => {
// Add user message
messages.value.push({
role: 'user',
content: variables,
timestamp: new Date()
})
// Add AI response
messages.value.push({
role: 'assistant',
content: data.content,
timestamp: new Date()
})
}
})
return {
messages,
sendMessage: sendMessageMutation.mutate,
isPending: sendMessageMutation.isPending,
error: sendMessageMutation.error
}
}
computed
and watchEffect
v-once
and v-memo
where appropriateshallowRef
for large objects that don't need deep reactivity<template>
<div class="flex flex-col h-full">
<!-- Chat Messages -->
<div class="flex-1 overflow-y-auto p-4 space-y-4" ref="messagesContainer">
<template v-if="messages.length === 0">
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<ChatBubbleLeftIcon class="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No messages yet. Start a conversation!</p>
</div>
</template>
<div v-for="(message, index) in messages" :key="index" class="flex"
:class="message.role === 'user' ? 'justify-end' : 'justify-start'">
<div class="max-w-3/4 rounded-lg px-4 py-2"
:class="message.role === 'user'
? 'bg-indigo-100 text-indigo-900 dark:bg-indigo-900 dark:text-indigo-100'
: 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'">
<div class="prose dark:prose-invert" v-html="formatMessage(message.content)"></div>
</div>
</div>
<div v-if="isProcessing" class="flex justify-start">
<div class="max-w-3/4 rounded-lg px-4 py-2 bg-gray-100 dark:bg-gray-800">
<div class="flex items-center space-x-2">
<div class="w-2 h-2 rounded-full bg-gray-400 animate-pulse"></div>
<div class="w-2 h-2 rounded-full bg-gray-400 animate-pulse delay-75"></div>
<div class="w-2 h-2 rounded-full bg-gray-400 animate-pulse delay-150"></div>
</div>
</div>
</div>
</div>
<!-- Input Form -->
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
<form @submit.prevent="sendMessage" class="flex space-x-2">
<textarea
v-model="newMessage"
@keydown.enter.prevent="sendMessage"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white"
placeholder="Type your message..."
rows="1"
:disabled="isProcessing"
></textarea>
<button
type="submit"
:disabled="isProcessing || !newMessage.trim()"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<PaperAirplaneIcon class="h-5 w-5" />
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onUpdated, nextTick } from 'vue'
import { ChatBubbleLeftIcon, PaperAirplaneIcon } from '@heroicons/vue/24/outline'
import { useAIAssistantStore } from '@/stores/aiAssistant'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const aiStore = useAIAssistantStore()
const messages = computed(() => aiStore.conversationHistory)
const isProcessing = computed(() => aiStore.isProcessing)
const newMessage = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
// Scroll to bottom when new messages are added
onUpdated(() => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
})
// Format message content with markdown
function formatMessage(content: string): string {
return DOMPurify.sanitize(marked(content))
}
// Send a new message
async function sendMessage() {
if (!newMessage.value.trim() || isProcessing.value) return
const message = newMessage.value
newMessage.value = ''
await aiStore.sendMessage(message)
}
</script>