Files
FabledGrimoire/frontend/src/stores/chat.ts
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

229 lines
6.1 KiB
TypeScript

import { ref, computed } from "vue";
import { defineStore } from "pinia";
import {
apiGet,
apiPost,
apiPatch,
apiDelete,
apiStreamPost,
} from "@/api/client";
import type {
Conversation,
ConversationDetail,
Message,
OllamaModel,
OllamaStatus,
} from "@/types/chat";
export const useChatStore = defineStore("chat", () => {
const conversations = ref<Conversation[]>([]);
const currentConversation = ref<ConversationDetail | null>(null);
const total = ref(0);
const loading = ref(false);
const streaming = ref(false);
const streamingContent = ref("");
const models = ref<OllamaModel[]>([]);
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "ready" | "not_found">("checking");
const defaultModel = ref("");
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
const chatReady = computed(
() => ollamaStatus.value === "available" && modelStatus.value === "ready"
);
async function fetchConversations(limit = 50, offset = 0) {
loading.value = true;
try {
const data = await apiGet<{ conversations: Conversation[]; total: number }>(
`/api/chat/conversations?limit=${limit}&offset=${offset}`
);
conversations.value = data.conversations;
total.value = data.total;
} finally {
loading.value = false;
}
}
async function createConversation(
title = "",
model = ""
): Promise<Conversation> {
const conv = await apiPost<Conversation>("/api/chat/conversations", {
title,
model,
});
conversations.value.unshift(conv);
return conv;
}
async function fetchConversation(id: number) {
loading.value = true;
try {
currentConversation.value = await apiGet<ConversationDetail>(
`/api/chat/conversations/${id}`
);
} finally {
loading.value = false;
}
}
async function deleteConversation(id: number) {
await apiDelete(`/api/chat/conversations/${id}`);
conversations.value = conversations.value.filter((c) => c.id !== id);
if (currentConversation.value?.id === id) {
currentConversation.value = null;
}
}
async function updateTitle(id: number, title: string) {
const updated = await apiPatch<Conversation>(
`/api/chat/conversations/${id}`,
{ title }
);
const idx = conversations.value.findIndex((c) => c.id === id);
if (idx !== -1) {
conversations.value[idx] = { ...conversations.value[idx], ...updated };
}
if (currentConversation.value?.id === id) {
currentConversation.value.title = updated.title;
}
}
async function sendMessage(content: string, contextNoteId?: number | null) {
if (!currentConversation.value) return;
const convId = currentConversation.value.id;
// Add optimistic user message
const userMsg: Message = {
id: -Date.now(), // Temporary ID
conversation_id: convId,
role: "user",
content,
context_note_id: contextNoteId ?? null,
created_at: new Date().toISOString(),
};
currentConversation.value.messages.push(userMsg);
streaming.value = true;
streamingContent.value = "";
try {
await apiStreamPost(
`/api/chat/conversations/${convId}/messages`,
{ content, context_note_id: contextNoteId },
(data) => {
if (data.chunk) {
streamingContent.value += data.chunk as string;
}
if (data.done) {
// Add the final assistant message
const assistantMsg: Message = {
id: data.message_id as number,
conversation_id: convId,
role: "assistant",
content: streamingContent.value,
context_note_id: null,
created_at: new Date().toISOString(),
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
}
streamingContent.value = "";
streaming.value = false;
// Update conversation in list
const idx = conversations.value.findIndex((c) => c.id === convId);
if (idx !== -1) {
conversations.value[idx].message_count += 2;
conversations.value[idx].updated_at = new Date().toISOString();
}
}
if (data.error) {
streaming.value = false;
streamingContent.value = "";
}
}
);
} catch {
streaming.value = false;
streamingContent.value = "";
}
}
async function saveMessageAsNote(messageId: number) {
return await apiPost<Record<string, unknown>>(
`/api/chat/messages/${messageId}/save-as-note`,
{}
);
}
async function summarizeAsNote(conversationId: number) {
return await apiPost<Record<string, unknown>>(
`/api/chat/conversations/${conversationId}/summarize`,
{}
);
}
async function fetchStatus() {
try {
const data = await apiGet<OllamaStatus>("/api/chat/status");
ollamaStatus.value = data.ollama;
modelStatus.value = data.model;
defaultModel.value = data.default_model;
} catch {
ollamaStatus.value = "unavailable";
modelStatus.value = "not_found";
}
}
function startStatusPolling() {
fetchStatus();
stopStatusPolling();
statusPollTimer = setInterval(fetchStatus, 30_000);
}
function stopStatusPolling() {
if (statusPollTimer !== null) {
clearInterval(statusPollTimer);
statusPollTimer = null;
}
}
async function fetchModels() {
try {
const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
models.value = data.models;
} catch {
models.value = [];
}
}
return {
conversations,
currentConversation,
total,
loading,
streaming,
streamingContent,
models,
ollamaStatus,
modelStatus,
defaultModel,
chatReady,
fetchConversations,
createConversation,
fetchConversation,
deleteConversation,
updateTitle,
sendMessage,
saveMessageAsNote,
summarizeAsNote,
fetchModels,
fetchStatus,
startStatusPolling,
stopStatusPolling,
};
});