Files
FabledGrimoire/frontend/src/components/ChatPanel.vue
T
bvandeusenandClaude Opus 4.6 834fd80640 Add Ollama health check status indicator to chat views
Adds visibility into whether Ollama is reachable and the configured model
is ready before allowing chat. Prevents silent failures when the model is
still downloading or Ollama is unavailable.

- Backend: GET /api/chat/status endpoint checks Ollama /api/tags (5s timeout)
  Returns ollama availability + model readiness + default model name
- Frontend: OllamaStatus type, chat store polling (30s interval)
- ChatView: status dot + label in header (green/yellow/red), disabled send
- ChatPanel: compact status indicator in panel header, disabled send
- summary.md: updated with new endpoint, store changes, and component descriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:33:42 -05:00

329 lines
7.3 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
import { useChatStore } from "@/stores/chat";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
const props = defineProps<{
contextNoteId?: number | null;
}>();
const emit = defineEmits<{
close: [];
}>();
const store = useChatStore();
const messageInput = ref("");
const messagesEl = ref<HTMLElement | null>(null);
const streamingRendered = computed(() => {
if (!store.streamingContent) return "";
return renderMarkdown(store.streamingContent);
});
watch(
() => store.streamingContent,
() => scrollToBottom()
);
const statusLabel = computed(() => {
if (store.ollamaStatus === "unavailable") return "Unavailable";
if (store.modelStatus === "not_found") return "Downloading...";
if (store.chatReady) return "Connected";
return "Checking...";
});
const statusClass = computed(() => {
if (store.ollamaStatus === "unavailable") return "status-red";
if (store.modelStatus === "not_found") return "status-yellow";
if (store.chatReady) return "status-green";
return "status-yellow";
});
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
}
});
}
onMounted(() => {
store.startStatusPolling();
});
onUnmounted(() => {
store.stopStatusPolling();
});
async function sendMessage() {
const content = messageInput.value.trim();
if (!content || store.streaming) return;
// Auto-create conversation if none active
if (!store.currentConversation) {
const conv = await store.createConversation();
await store.fetchConversation(conv.id);
}
messageInput.value = "";
scrollToBottom();
await store.sendMessage(content, props.contextNoteId);
scrollToBottom();
}
async function handleSaveAsNote(messageId: number) {
try {
await store.saveMessageAsNote(messageId);
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Saved as note");
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Failed to save as note", "error");
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
</script>
<template>
<div class="chat-panel-overlay" @click.self="emit('close')">
<aside class="chat-panel">
<div class="panel-header">
<h3>Chat</h3>
<span class="status-indicator" :class="statusClass">
<span class="status-dot"></span>
{{ statusLabel }}
</span>
<span v-if="contextNoteId" class="context-indicator">
Note #{{ contextNoteId }}
</span>
<button class="btn-close" @click="emit('close')">&times;</button>
</div>
<div ref="messagesEl" class="panel-messages">
<template v-if="store.currentConversation">
<ChatMessage
v-for="msg in store.currentConversation.messages"
:key="msg.id"
:message="msg"
@save-as-note="handleSaveAsNote"
/>
</template>
<div v-if="store.streaming" class="chat-message role-assistant streaming">
<div class="message-header">
<span class="role-label">Assistant</span>
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span class="typing-indicator"></span>
</div>
<p
v-if="!store.currentConversation?.messages.length && !store.streaming"
class="empty-msg"
>
Start chatting...
</p>
</div>
<div class="panel-input">
<textarea
v-model="messageInput"
@keydown="onInputKeydown"
:placeholder="store.chatReady ? 'Type a message...' : statusLabel"
:disabled="store.streaming || !store.chatReady"
rows="2"
></textarea>
<button
class="btn-send"
@click="sendMessage"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
>
Send
</button>
</div>
</aside>
</div>
</template>
<style scoped>
.chat-panel-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 100;
display: flex;
justify-content: flex-end;
}
.chat-panel {
width: 400px;
max-width: 100vw;
height: 100%;
background: var(--color-bg);
display: flex;
flex-direction: column;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
}
.panel-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
flex: 1;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.status-green .status-dot {
background: #22c55e;
}
.status-yellow .status-dot {
background: #eab308;
animation: pulse-dot 2s infinite;
}
.status-red .status-dot {
background: #ef4444;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.context-indicator {
font-size: 0.8rem;
color: var(--color-primary);
background: var(--color-bg-secondary);
padding: 0.15rem 0.5rem;
border-radius: 4px;
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--color-text-muted);
line-height: 1;
padding: 0 0.25rem;
}
.btn-close:hover {
color: var(--color-text);
}
.panel-messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.streaming {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.75rem 1rem;
background: var(--color-bg-card);
margin-bottom: 0.5rem;
}
.streaming .message-header {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
}
.streaming .role-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
}
.streaming .message-content {
font-size: 0.95rem;
line-height: 1.5;
word-break: break-word;
}
.typing-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
animation: blink 1s infinite;
margin-left: 4px;
vertical-align: middle;
}
@keyframes blink {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.panel-input {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.panel-input textarea {
flex: 1;
resize: none;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
font-family: inherit;
font-size: 0.9rem;
background: var(--color-bg);
color: var(--color-text);
}
.panel-input textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.btn-send {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
align-self: flex-end;
}
.btn-send:disabled {
opacity: 0.5;
cursor: default;
}
.empty-msg {
color: var(--color-text-muted);
font-size: 0.9rem;
text-align: center;
padding: 2rem 1rem;
}
@media (max-width: 480px) {
.chat-panel {
width: 100vw;
}
}
</style>