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>
229 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
});
|