new selfhosted version

This commit is contained in:
antonio
2025-11-28 14:11:51 +01:00
parent afda29997d
commit 951860f67e
1046 changed files with 72586 additions and 574750 deletions

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { ReadableChatMessage } from '~/pages/ai.vue';
import AssistantMessage from './AssistantMessage.vue';
import { CircleAlert } from 'lucide-vue-next';
const ai_chats_component = useTemplateRef<HTMLDivElement>('ai_chats');
const props = defineProps<{
messages?: ReadableChatMessage[],
status?: string,
}>();
const emits = defineEmits<{
(event: 'downvoted', message_index: number): void;
(event: 'chatdeleted'): void;
}>();
function scrollToBottom() {
setTimeout(() => {
ai_chats_component.value?.scrollTo({ top: 999999, behavior: 'smooth' });
}, 150);
}
watch(props, async () => {
scrollToBottom();
})
</script>
<template>
<div class="flex flex-col gap-2 overflow-y-auto overflow-x-hidden" ref="ai_chats">
<div v-for="(message, index) of messages" class="flex flex-col relative">
<div class="w-full flex justify-end" v-if="message.role === 'user'">
<div class="border rounded-md p-2 flex flex-col gap-2 flex-end w-[70%] bg-white dark:bg-black">
<div class="flex gap-2 items-center">
<Label> {{ message.name ?? 'User' }} </Label>
<Label class="text-sm text-muted-foreground" v-if="message.created_at">{{ new
Date(message.created_at).toLocaleString() }}</Label>
</div>
<div>
{{ message.content }}
</div>
</div>
</div>
<AssistantMessage v-if="message.role === 'assistant'" @messageRendered="scrollToBottom()"
@downvoted="emits('downvoted', $event)" :message="message" :message_index="index">
</AssistantMessage>
</div>
<div v-if="status?.startsWith('THINKING')" class="text-sm flex items-center gap-2">
<Loader class="!size-3"></Loader>
{{ status.split(':')[1] }} is thinking...
</div>
<div v-if="status?.startsWith('FUNCTION')" class="text-sm flex items-center gap-2">
<Loader class="!size-3"></Loader>
{{ status.split(':')[1] }} is calling a function...
</div>
<div v-if="status?.startsWith('FINDING_AGENT')" class="text-sm flex items-center gap-2">
<Loader class="!size-3"></Loader>
Finding best agents...
</div>
<div v-if="status?.startsWith('ERRORED')" class="flex items-center gap-2">
<CircleAlert class="text-orange-300 size-4"></CircleAlert>
<div v-if="messages && messages.length < 100"> An error occurred. Please use another chat. </div>
<div v-else> Context limit reached </div>
</div>
<DevOnly>
<div class="flex items-center gap-1 text-muted-foreground overflow-hidden">
<Icon name="gg:debug" size="20"></Icon>
<div v-if="status"> {{ status }} </div>
<div v-else> No Status </div>
</div>
</DevOnly>
</div>
</template>

View File

@@ -0,0 +1,292 @@
<script lang="ts" setup>
import type { MDCNode, MDCParserResult, MDCRoot } from '@nuxtjs/mdc';
import { InfoIcon, ThumbsDown, ThumbsUp } from 'lucide-vue-next';
import type { ReadableChatMessage } from '~/pages/ai.vue';
import AiChart from '~/components/complex/ai/Chart.vue'
const props = defineProps<{ message: ReadableChatMessage, message_index: number }>();
const parsedMessage = ref<MDCParserResult>();
const hidden = ref<boolean>(props.message.downvoted ?? false);
const emits = defineEmits<{
(event: 'messageRendered'): void;
(event: 'downvoted', index: number): void;
}>();
function removeEmbedImages(data: MDCRoot | MDCNode) {
if (data.type !== 'root' && data.type !== 'element') return;
if (!data.children) return;
const imgChilds = data.children.filter(e => e.type === 'element' && e.tag === 'img');
if (imgChilds.length == 0) return data.children.forEach(e => removeEmbedImages(e));
for (let i = 0; i < imgChilds.length; i++) {
const index = data.children.indexOf(imgChilds[i]);
console.log('Index', index)
if (index == -1) continue;
data.children.splice(index, 1);
}
return data.children.forEach(e => removeEmbedImages(e));
}
onMounted(async () => {
if (!props.message.content) return;
const parsed = await parseMarkdown(props.message.content);
await new Promise(e => setTimeout(e, 200));
parsedMessage.value = parsed;
removeEmbedImages(parsed.body);
emits('messageRendered');
})
const AI_MAP: Record<string, { img: string, color: string }> = {
GrowthAgent: { img: '/ai/growth.png', color: '#ff861755' },
MarketingAgent: { img: '/ai/marketing.png', color: '#bf7fff55' },
ProductAgent: { img: '/ai/product.png', color: '#00f33955' },
}
const messageStyle = computed(() => {
if (!props.message.name) return;
const target = AI_MAP[props.message.name];
if (!target) return '';
return `background-color: ${target.color};`
});
const isContentMessage = computed(() => !props.message.tool_calls && props.message.content && !hidden.value);
const isHiddenMessage = computed(() => !props.message.tool_calls && props.message.content && hidden.value);
const isToolMessage = computed(() => props.message.tool_calls);
function downvoteMessage() {
emits('downvoted', props.message_index)
hidden.value = true;
}
</script>
<template>
<div class="w-full flex justify-start ml-4">
<div v-if="isToolMessage" class="flex flex-col w-[70%] gap-3">
<div class="flex flex-col gap-2 flex-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger class="w-fit">
<div class="flex gap-1 items-center text-sm w-fit">
<InfoIcon class="size-4"></InfoIcon>
<div> The ai will use some functions </div>
</div>
</TooltipTrigger>
<TooltipContent>
<div class="font-semibold" v-for="tool of message.tool_calls">
{{ tool.function.name }}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div v-if="isToolMessage && message.tool_calls?.[0].function.name === 'createChart'"
class="flex flex-col gap-2 flex-end">
<AiChart :data="JSON.parse(message.tool_calls[0].function.arguments)"></AiChart>
</div>
</div>
<div v-if="isContentMessage" :style="messageStyle"
class="border rounded-md p-2 flex flex-col gap-2 flex-end w-[70%] relative agent-message-with-content border-accent-foreground/20">
<div class="absolute left-[-1rem] top-[-1rem] rotate-[-15deg]">
<img v-if="message.name && AI_MAP[message.name]" class="h-[3rem]" :src="AI_MAP[message.name].img">
</div>
<div class="flex gap-2 items-center">
<img class="w-5 h-auto" :src="'/ai/pixel-boy.png'">
<Label> {{ message.name ?? 'AI' }} </Label>
<Label class="text-sm text-muted-foreground" v-if="message.created_at">
{{ new Date(message.created_at).toLocaleString() }}
</Label>
</div>
<MDCRenderer class="md-content !text-gray-800 dark:!text-white" v-if="parsedMessage" :body="parsedMessage.body"
:data="parsedMessage.data" />
<Skeleton v-if="!parsedMessage" class="w-full h-[5rem]"></Skeleton>
</div>
<div v-if="isHiddenMessage" :style="messageStyle"
class="border rounded-md p-2 flex flex-col gap-2 flex-end w-[70%] relative">
<div class="absolute left-[-1rem] top-[-1rem] rotate-[-15deg]">
<img v-if="message.name && AI_MAP[message.name]" class="h-[3rem]" :src="AI_MAP[message.name].img">
</div>
<div class="flex gap-2 items-center ml-6">
<Label> {{ message.name ?? 'AI' }} </Label>
<Label class="text-sm text-muted-foreground" v-if="message.created_at">
{{ new Date(message.created_at).toLocaleString() }}
</Label>
</div>
<div>
Message deleted becouse downvoted
</div>
</div>
<div v-if="isContentMessage" class="flex ml-2 items-end gap-2">
<ThumbsDown @click="downvoteMessage()" :class="{ 'text-red-400': message.downvoted }" class="size-4">
</ThumbsDown>
</div>
</div>
</template>
<style lang="scss" scoped>
.agent-message-with-content .md-content {
&:deep() {
font-family: system-ui, sans-serif;
line-height: 1.5;
color: white;
font-size: 1rem;
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 700;
line-height: 1.25;
margin: 2rem 0 1rem;
scroll-margin-top: 100px;
}
h1 {
font-size: 2rem;
border-bottom: 1px solid #ddd;
padding-bottom: 0.3rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
h4 {
font-size: 1.125rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.875rem;
}
// Paragraphs
p {
margin: 1rem 0;
}
// Links
a {
cursor: default;
pointer-events: none;
}
// Lists
ul,
ol {
padding-left: 1.5rem;
margin: 1rem 0;
li {
margin: 0.5rem 0;
}
}
// Blockquote
blockquote {
margin: 1.5rem 0;
padding: 1rem 1.5rem;
border-left: 4px solid #ccc;
background-color: #f9f9f9;
color: #555;
font-style: italic;
}
// Code blocks
pre {
background: #1e1e1e;
color: #dcdcdc;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
font-family: 'Fira Code', monospace;
font-size: 0.9rem;
margin: 1.5rem 0;
}
code {
background: #f3f3f3;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.9rem;
}
pre code {
background: none;
padding: 0;
}
// Tables
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.95rem;
th,
td {
padding: 0.75rem;
text-align: left;
}
th {
background-color: #0000006c;
}
tr:nth-child(even) {
background-color: #ffffff23;
}
}
// Images
img {
max-width: 100%;
height: auto;
display: block;
margin: 1rem 0;
border-radius: 8px;
}
// Horizontal rule
hr {
border: none;
border-top: 1px solid #ccc;
margin: 2rem 0;
}
}
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts" setup>
import type { ChartData, ChartOptions } from 'chart.js';
import { useLineChart, LineChart } from 'vue-chart-3';
export type AiChartData = {
labels: string[],
title: string,
datasets: {
chartType: 'line' | 'bar',
points: number[],
color: string,
name: string
}[]
}
const props = defineProps<{ data: AiChartData }>();
const chartColor = useChartColor();
const chartOptions = shallowRef<ChartOptions<'line'>>({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'nearest',
axis: 'x',
includeInvisible: true
},
scales: {
y: {
ticks: { display: true },
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
},
beginAtZero: true,
},
x: {
ticks: { display: true },
stacked: false,
offset: false,
grid: {
display: true,
drawBorder: false,
color: '#CCCCCC22',
}
}
},
plugins: {
legend: { display: false },
title: { display: false }
},
});
const chartData = shallowRef<ChartData<'line' | 'bar'>>({
labels: props.data.labels,
datasets: props.data.datasets.map(e => {
return {
label: e.name,
data: e.points,
borderColor: e.color ?? '#0000CC',
type: e.chartType,
backgroundColor: [e.color ?? '#0000CC']
}
})
});
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
</script>
<template>
<LineChart v-if="chartData" ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
</template>

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import { AlertCircle, TrashIcon } from 'lucide-vue-next';
import type { TAiNewChatSchema } from '~/shared/schema/ai/AiNewChatSchema';
const props = defineProps<{ chats: TAiNewChatSchema[] }>();
const emits = defineEmits<{
(event: 'selectChat', chat_id: string): void;
(event: 'deleteAllChats'): void;
(event: 'deleteChat', chat_id: string): void;
}>();
const separatorIndex = props.chats.toReversed().findIndex(e => new Date(e.updated_at).getUTCDay() < new Date().getUTCDay());
</script>
<template>
<div class="flex flex-col gap-4 overflow-hidden h-full">
<div class="flex flex-col gap-2">
<Button @click="emits('deleteAllChats')" size="sm" class="w-full" variant="destructive">
Delete all
</Button>
<Button @click="emits('selectChat', 'null')" size="sm" class="w-full" variant="secondary">
New chat
</Button>
</div>
<div class="flex flex-col gap-2 overflow-y-auto h-full pr-2 pb-[10rem]">
<div v-for="(chat, index) of chats.toReversed()">
<div v-if="separatorIndex === index" class="flex flex-col items-center mt-2 mb-2">
<Label class="text-muted-foreground"> Older chats </Label>
</div>
<div class="flex items-center gap-2 rounded-md border p-2">
<TooltipProvider>
<Tooltip :delay-duration="700">
<TooltipTrigger class="grow cursor-pointer flex gap-2 items-center"
@click="emits('selectChat', chat._id.toString())">
<AlertCircle v-if="chat.status === 'ERRORED'" class="size-4 shrink-0 text-orange-300">
</AlertCircle>
<div class="text-ellipsis line-clamp-1 text-left">
{{ chat.title }}
</div>
</TooltipTrigger>
<TooltipContent>
{{ chat.status === 'ERRORED' ? '[ERROR]' : '' }} {{ chat.title }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div class="shrink-0 cursor-pointer hover:text-red-400">
<TrashIcon @click="emits('deleteChat', chat._id.toString())" class="size-4"></TrashIcon>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<script lang="ts" setup>
import { ArrowUp, Flame, List, MessageSquareText, TriangleAlert } from 'lucide-vue-next'
const emits = defineEmits<{
(event: 'sendprompt', message: string): void;
(event: 'open-sheet'): void;
}>();
const prompts = [
'What traffic sources brought the most visitors last week?',
'Show me the user retention rate for the past month',
"How many users visited the website yesterday?",
"Did our traffic increase compared to last month?",
"Which page had the most views yesterday?",
"Did users spend more time on site this week than last?",
"Are desktop users staying longer than mobile users?",
"Did our top 5 countries change this month?",
"How many users visited the website yesterday?",
]
const input = ref('')
const toggleSet = ref('')
function onKeyPress(e: any) {
if (e.key === 'Enter') emits('sendprompt', input.value);
}
const checkInput = computed(() => input.value.trim().length > 0)
const handleSubmit = () => {
if (!input.value.trim()) return
console.log('Inviato:', input.value)
input.value = ''
}
//Effetto macchina da scrivere desiderato da fratello antonio
const baseText = 'Ask me about... '
const placeholder_texts = ['your Month over Month growth in visits', 'your top traffic source last week', 'how long visitors stick around', 'how can I help you', 'to turn your visitor data into a bar chart']
const placeholder = ref('')
const typingSpeed = 35
const pauseAfterTyping = 800
const pauseAfterDeleting = 400
let index = 0
let charIndex = 0
let isDeleting = false
let typingTimeout: ReturnType<typeof setTimeout> | null = null
function startTyping() {
const current = placeholder_texts[index]
placeholder.value = baseText + current.substring(0, charIndex)
if (!isDeleting) {
if (charIndex < current.length) {
charIndex++
typingTimeout = setTimeout(startTyping, typingSpeed)
} else {
typingTimeout = setTimeout(() => {
isDeleting = true
startTyping()
}, pauseAfterTyping)
}
} else {
if (charIndex > 0) {
charIndex--
typingTimeout = setTimeout(startTyping, typingSpeed)
} else {
isDeleting = false
index = (index + 1) % placeholder_texts.length
typingTimeout = setTimeout(startTyping, pauseAfterDeleting)
}
}
}
function resetTyping() {
if (typingTimeout) clearTimeout(typingTimeout)
index = 0
charIndex = 0
isDeleting = false
startTyping()
}
onMounted(() => {
startTyping()
})
watch(input, (newValue) => {
if (newValue === '') {
resetTyping()
}
})
</script>
<template>
<div class="h-dvh flex items-center justify-center poppins">
<div class="w-full max-w-2xl space-y-4">
<div class="flex flex-col items-center">
<div class="text-center mb-4">
<h1 class="text-2xl font-medium dark:text-white text-violet-500 tracking-tight">
AI Assistant
</h1>
<p class="text-sm text-gray-400 dark:text-zinc-400 mt-1">
A dedicated team of smart AI experts on Marketing, Growth and Product.
</p>
</div>
<!-- <Alert class="border-yellow-500">
<TriangleAlert class="size-4 !text-yellow-500"/>
<AlertTitle>Our AI is still in development we know its scrappy.</AlertTitle>
<AlertDescription>
Using it helps us learn what you really need. Got feedback? Wed love to hear it!
</AlertDescription>
</Alert> -->
</div>
<!-- Input container -->
<div class="relative bg-gray-200 dark:bg-zinc-800 rounded-2xl p-4 shadow-md flex flex-col gap-4">
<div
class="absolute z-0 border-2 animate-pulse border-violet-500 w-full h-full top-0 left-0 rounded-[14px]">
</div>
<div class="w-full relative z-10">
<Input v-model="input" :placeholder="placeholder"
class="pl-0 !bg-transparent !border-none shadow-none text-gray-600 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 !outline-none !ring-0"
@keypress="onKeyPress" />
</div>
<div class="flex justify-between items-center gap-2 relative z-10">
<ToggleGroup type="single" variant="outline" v-model="toggleSet">
<ToggleGroupItem value="prompts" aria-label="Toggle italic">
<span class="text-sm font-normal items-center flex gap-2">
<List class="size-4" /> Prompts
</span>
</ToggleGroupItem>
</ToggleGroup>
<div class="flex gap-2">
<Button size="icon" @click="emits('open-sheet')" variant="ghost">
<MessageSquareText class="size-4" />
</Button>
<Button size="icon" @click="emits('sendprompt', input)" :disabled="!checkInput">
<ArrowUp class="size-4" />
</Button>
</div>
</div>
</div>
<div class="overflow-hidden transition-all duration-300"
:class="toggleSet === 'prompts' ? 'max-h-40 opacity-100 overflow-y-auto' : 'max-h-0 opacity-0'">
<div class="rounded-md flex flex-col gap-2">
<Button v-for="p of prompts" variant="outline" @click="emits('sendprompt', p)" class="truncate">{{ p
}}</Button>
<!-- <NuxtLink to="#">
<Button variant="link">View complete list</Button>
</NuxtLink> -->
</div>
</div>
</div>
</div>
</template>