AlgoVision / frontend /app /components /ChatInterface.vue
AlgoVision Deployer
deploy: minimal bootloader for public Space
1a25b7f
<template>
<div class="flex flex-col h-full bg-slate-50/30">
<!-- Header Controls -->
<div
v-if="messages.length > 0"
class="px-4 py-2 border-b border-slate-100 flex justify-end shrink-0"
>
<UButton
icon="i-lucide-trash-2"
color="gray"
variant="ghost"
size="xs"
label="Clear Chat"
@click="chatStore.clearHistory(project.id)"
class="text-slate-400 hover:text-red-500 transition-colors"
/>
</div>
<div
class="flex-1 p-4 overflow-y-auto custom-scrollbar space-y-4"
ref="chatContainer"
>
<!-- Welcome message -->
<div
v-if="messages.length === 0"
class="h-full flex flex-col items-center justify-center text-center p-6 animate-fade-in-up"
>
<div
class="w-16 h-16 rounded-2xl bg-gradient-to-br from-green-400 to-green-600 flex items-center justify-center mb-6 shadow-lg shadow-green-500/20"
>
<UIcon name="i-lucide-bot" class="w-8 h-8 text-white" />
</div>
<h3 class="font-bold text-slate-900 mb-2">AI Tutor Ready</h3>
<p class="text-sm text-slate-500 mb-8 max-w-[240px]">
Ask me any questions about the video or educational concepts.
</p>
<!-- Vertical Suggestion Chips -->
<div
v-if="suggestions.length > 0"
class="flex flex-col gap-2.5 w-full max-w-[300px]"
>
<p
class="text-[10px] uppercase tracking-[0.1em] font-bold text-slate-400 mb-1 text-left px-1"
>
Suggested for you
</p>
<button
v-for="suggestion in suggestions"
:key="suggestion"
class="group flex items-center gap-3 px-4 py-3 rounded-xl bg-white border border-slate-100 text-left text-sm text-slate-700 hover:border-green-500 hover:bg-green-50/30 transition-all duration-200 shadow-sm hover:shadow-md"
@click="useSuggestion(suggestion)"
>
<UIcon
name="i-lucide-sparkles"
class="w-4 h-4 text-green-500 opacity-40 group-hover:opacity-100 transition-opacity shrink-0"
/>
<span class="whitespace-normal leading-relaxed text-slate-700">{{
suggestion
}}</span>
</button>
</div>
</div>
<!-- Messages -->
<div
v-for="(msg, i) in messages"
:key="i"
class="flex animate-fade-in-up"
:class="msg.role === 'user' ? 'justify-end' : 'justify-start'"
>
<!-- User Bubble -->
<div
v-if="msg.role === 'user'"
class="max-w-[85%] p-4 rounded-2xl shadow-sm text-sm leading-relaxed border transition-all bg-green-50/50 border-green-100 text-slate-800 rounded-tr-sm"
>
<div
class="prose prose-sm max-w-none text-slate-800 font-medium prose-p:mb-0 last:prose-p:mb-0 prose-code:bg-slate-200/60 prose-code:text-slate-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded"
v-html="renderMarkdown(msg.content)"
></div>
</div>
<!-- AI Message - Clean text, no avatar, no bubble -->
<div v-else class="w-full transition-all duration-200">
<div
class="prose prose-sm max-w-none prose-p:mb-4 last:prose-p:mb-0 prose-p:text-slate-700 prose-p:leading-7 prose-headings:text-slate-900 prose-headings:font-bold prose-headings:mb-3 prose-headings:mt-6 first:prose-headings:mt-0 prose-h2:text-base prose-h2:border-b prose-h2:border-slate-200 prose-h2:pb-2 prose-h3:text-sm prose-h3:text-slate-900 prose-h3:font-medium prose-h4:text-sm prose-h4:text-slate-800 prose-h4:font-medium prose-strong:text-slate-900 prose-strong:font-semibold prose-ul:list-disc prose-ul:pl-5 prose-ul:mb-4 prose-ul:space-y-1.5 prose-li:text-slate-700 prose-ol:list-decimal prose-ol:pl-5 prose-ol:mb-4 prose-ol:space-y-1.5 prose-li:text-slate-700 prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-emerald-700 prose-code:font-mono prose-code:text-[0.85em] prose-code:before:content-none prose-code:after:content-none prose-pre:bg-[#0f172a] prose-pre:text-green-400 prose-pre:border prose-pre:border-slate-800 prose-pre:rounded-lg prose-pre:p-4 prose-pre:mt-4 prose-pre:mb-4 prose-pre:overflow-x-auto prose-blockquote:border-l-2 prose-blockquote:border-emerald-300 prose-blockquote:bg-emerald-50/30 prose-blockquote:pl-4 prose-blockquote:pr-4 prose-blockquote:py-3 prose-blockquote:rounded-r-xl prose-blockquote:italic prose-blockquote:text-slate-600 prose-blockquote:my-4 prose-table:w-full prose-table:border-collapse prose-table:my-4 prose-th:bg-slate-50 prose-th:border prose-th:border-slate-200 prose-th:px-3 prose-th:py-2 prose-th:text-left prose-th:text-xs prose-th:font-bold prose-th:text-slate-700 prose-td:border prose-td:border-slate-200 prose-td:px-3 prose-td:py-2 prose-td:text-sm prose-td:text-slate-700 prose-hr:border-slate-200 prose-hr:my-6 prose-a:text-emerald-600 prose-a:underline prose-a:decoration-emerald-600/30 prose-a:underline-offset-2 hover:prose-a:decoration-emerald-600/60 space-y-2"
v-html="renderMarkdown(msg.content)"
></div>
</div>
</div>
<!-- Loading Indicator -->
<div v-if="isTyping" class="flex justify-start animate-fade-in">
<div
class="bg-green-50 border border-green-100 rounded-2xl rounded-tl-sm px-4 py-3 flex items-center gap-1.5 shadow-sm"
>
<span
class="w-1.5 h-1.5 bg-green-400 rounded-full animate-bounce"
></span>
<span
class="w-1.5 h-1.5 bg-green-400 rounded-full animate-bounce"
style="animation-delay: 0.15s"
></span>
<span
class="w-1.5 h-1.5 bg-green-400 rounded-full animate-bounce"
style="animation-delay: 0.3s"
></span>
</div>
</div>
</div>
<!-- Input Area -->
<div class="bg-white/80 border-t border-slate-100 shrink-0">
<div class="p-4">
<form @submit.prevent="sendMessage" class="relative group">
<input
v-model="newMessage"
type="text"
@keydown.enter.prevent="sendMessage"
:placeholder="
projectStore.loadingModels
? 'Loading AI models...'
: 'Ask a question...'
"
class="w-full premium-input rounded-xl pl-4 pr-12 py-3 text-sm outline-none disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isTyping || projectStore.loadingModels"
/>
<button
type="submit"
:disabled="
!newMessage.trim() || isTyping || projectStore.loadingModels
"
class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg bg-green-500 text-white hover:bg-green-600 disabled:opacity-50 disabled:bg-slate-300 transition-colors"
:title="
projectStore.loadingModels ? 'Models loading' : 'Send message'
"
>
<UIcon
:name="
projectStore.loadingModels
? 'i-lucide-loader-2'
: 'i-lucide-send'
"
class="w-4 h-4"
:class="
projectStore.loadingModels
? 'animate-spin'
: '-translate-x-[1px] translate-y-[1px]'
"
/>
</button>
</form>
<div class="mt-2 text-[10px] text-center text-slate-400">
AlgoVision can make mistakes. Check important information.
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed } from "vue";
import { marked } from "marked";
import markedKatex from "marked-katex-extension";
import "katex/dist/katex.min.css";
import api from "~/utils/api";
import { useProjectStore } from "~/stores/projects";
import { useChatStore } from "~/stores/chat";
marked.use(
markedKatex({
throwOnError: false,
output: "html",
}),
);
const props = defineProps({
project: { type: Object, required: true },
});
const projectStore = useProjectStore();
const chatStore = useChatStore();
const messages = computed(() => chatStore.getHistory(props.project.id));
const suggestions = computed(() => chatStore.getSuggestions(props.project.id));
const isTyping = ref(false);
const newMessage = ref("");
const chatContainer = ref(null);
const fetchSuggestions = async () => {
try {
await chatStore.fetchSuggestions(props.project.id, projectStore.apiKeys);
} catch (err) {
console.error("Error fetching suggestions:", err);
}
};
const useSuggestion = (text) => {
newMessage.value = text;
sendMessage();
};
const scrollToBottom = async () => {
await nextTick();
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
};
onMounted(() => {
scrollToBottom();
if (!projectStore.loadingModels) {
fetchSuggestions();
}
});
watch(
() => projectStore.loadingModels,
(loading) => {
if (!loading && suggestions.value.length === 0) {
fetchSuggestions();
}
},
);
watch(messages, scrollToBottom, { deep: true });
watch(isTyping, scrollToBottom);
const renderMarkdown = (text) => {
if (!text) return "";
try {
return marked.parse(text);
} catch (e) {
return text;
}
};
const sendMessage = async () => {
if (!newMessage.value.trim() || isTyping.value) return;
const userText = newMessage.value;
chatStore.addMessage(props.project.id, { role: "user", content: userText });
newMessage.value = "";
isTyping.value = true;
try {
const res = await api.chat(props.project.id, {
message: userText,
history: messages.value.slice(0, -1),
model:
projectStore.planningModel ||
projectStore.selectedModel ||
"gemini/gemini-3.1-flash-latest",
openai_api_key: projectStore.apiKeys.openai,
anthropic_api_key: projectStore.apiKeys.anthropic,
gemini_api_key: projectStore.apiKeys.gemini,
});
chatStore.addMessage(props.project.id, {
role: "assistant",
content: res.response,
});
} catch (error) {
console.error("Chat error:", error);
chatStore.addMessage(props.project.id, {
role: "assistant",
content: `**Error:** Failed to connect to the AI engine. ${error.response?.data?.detail || error.message}`,
});
} finally {
isTyping.value = false;
}
};
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(203, 213, 225, 0.5);
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.8);
}
/* Additional prose polish for AI responses */
:deep(.prose a) {
color: #059669;
text-decoration: underline;
text-decoration-color: rgba(5, 150, 105, 0.3);
text-underline-offset: 2px;
transition: all 0.15s ease;
}
:deep(.prose a:hover) {
color: #047857;
text-decoration-color: rgba(5, 150, 105, 0.6);
}
/* Inline math/latex */
:deep(.prose .katex) {
font-size: 0.95em;
}
/* Display math blocks */
:deep(.prose .katex-display) {
margin: 0.75rem 0;
padding: 0.75rem;
overflow-x: auto;
}
/* Smooth text rendering */
:deep(.prose *) {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Code block line numbers area (if any) */
:deep(.prose pre code) {
font-family:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
monospace;
}
/* Prevent code wrap */
:deep(.prose pre) {
white-space: pre;
word-break: normal;
overflow-wrap: normal;
}
/* Table responsiveness */
:deep(.prose table) {
display: table;
width: 100%;
overflow-x: auto;
}
</style>