Add LLM chat integration with streaming responses via Ollama
Phase 4: Full chat system with SSE streaming, note-aware context, and conversation persistence. Backend: - Migration 0005: conversations + messages tables with FKs and indexes - Conversation/Message SQLAlchemy models with relationships - LLM service: ensure_model (auto-pull on startup), stream_chat (NDJSON), generate_completion, fetch_url_content (HTML stripping), build_context (keyword extraction, related note search, URL content injection) - Chat service: conversation CRUD, save_response_as_note, summarize_conversation_as_note - Chat routes blueprint: 9 endpoints including SSE streaming for messages, save/summarize as note, Ollama model listing - Auto-pull llama3.1 model on app startup (non-blocking) Frontend: - apiStreamPost: SSE client using fetch + ReadableStream - Chat Pinia store with streaming state management - ChatView: dedicated /chat page with conversation sidebar + message thread - ChatPanel: slide-out panel with contextNoteId from current route - ChatMessage: markdown-rendered message bubble with "Save as Note" action - Updated AppHeader with Chat nav link + panel toggle button - Updated App.vue to mount ChatPanel with route-derived context - Added /chat and /chat/:id routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPatch,
|
||||
apiDelete,
|
||||
apiStreamPost,
|
||||
} from "@/api/client";
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
Message,
|
||||
OllamaModel,
|
||||
} 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[]>([]);
|
||||
|
||||
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 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,
|
||||
fetchConversations,
|
||||
createConversation,
|
||||
fetchConversation,
|
||||
deleteConversation,
|
||||
updateTitle,
|
||||
sendMessage,
|
||||
saveMessageAsNote,
|
||||
summarizeAsNote,
|
||||
fetchModels,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user