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,68 @@
|
|||||||
|
"""add chat tables
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2025-01-01 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0005"
|
||||||
|
down_revision = "0004"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"conversations",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("title", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("model", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"messages",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"conversation_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("role", sa.Text(), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column(
|
||||||
|
"context_note_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("ix_messages_conversation_id", "messages", ["conversation_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_messages_conversation_id", table_name="messages")
|
||||||
|
op.drop_table("messages")
|
||||||
|
op.drop_table("conversations")
|
||||||
@@ -11,6 +11,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
|
||||||
OLLAMA_URL: "http://ollama:11434"
|
OLLAMA_URL: "http://ollama:11434"
|
||||||
|
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
|
||||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
|
|||||||
+26
-1
@@ -1,13 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
import AppHeader from "@/components/AppHeader.vue";
|
import AppHeader from "@/components/AppHeader.vue";
|
||||||
|
import ChatPanel from "@/components/ChatPanel.vue";
|
||||||
import ToastNotification from "@/components/ToastNotification.vue";
|
import ToastNotification from "@/components/ToastNotification.vue";
|
||||||
import { useTheme } from "@/composables/useTheme";
|
import { useTheme } from "@/composables/useTheme";
|
||||||
|
|
||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const chatPanelOpen = ref(false);
|
||||||
|
|
||||||
|
const contextNoteId = computed(() => {
|
||||||
|
const id = route.params.id;
|
||||||
|
if (!id) return null;
|
||||||
|
const path = route.path;
|
||||||
|
if (path.startsWith("/notes/") || path.startsWith("/tasks/")) {
|
||||||
|
return Number(id);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleChatPanel() {
|
||||||
|
chatPanelOpen.value = !chatPanelOpen.value;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppHeader />
|
<AppHeader @toggle-chat-panel="toggleChatPanel" />
|
||||||
<router-view />
|
<router-view />
|
||||||
|
<ChatPanel
|
||||||
|
v-if="chatPanelOpen"
|
||||||
|
:context-note-id="contextNoteId"
|
||||||
|
@close="chatPanelOpen = false"
|
||||||
|
/>
|
||||||
<ToastNotification />
|
<ToastNotification />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -48,3 +48,55 @@ export async function apiDelete(path: string): Promise<void> {
|
|||||||
throw new Error(`API error: ${res.status}`);
|
throw new Error(`API error: ${res.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiStreamPost(
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
onChunk: (data: Record<string, unknown>) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API error: ${res.status}`);
|
||||||
|
}
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
if (!reader) throw new Error("No response body");
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
// Keep the last (possibly incomplete) line in the buffer
|
||||||
|
buffer = lines.pop() || "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith("data: ")) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(trimmed.slice(6));
|
||||||
|
onChunk(data);
|
||||||
|
} catch {
|
||||||
|
// Skip malformed JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any remaining buffer
|
||||||
|
if (buffer.trim().startsWith("data: ")) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(buffer.trim().slice(6));
|
||||||
|
onChunk(data);
|
||||||
|
} catch {
|
||||||
|
// Skip malformed JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
import { useTheme } from "@/composables/useTheme";
|
import { useTheme } from "@/composables/useTheme";
|
||||||
|
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleChatPanel: [];
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -11,6 +15,10 @@ const { theme, toggleTheme } = useTheme();
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<router-link to="/notes" class="nav-link">Notes</router-link>
|
<router-link to="/notes" class="nav-link">Notes</router-link>
|
||||||
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
<router-link to="/tasks" class="nav-link">Tasks</router-link>
|
||||||
|
<router-link to="/chat" class="nav-link">Chat</router-link>
|
||||||
|
<button class="btn-chat-panel" @click="emit('toggleChatPanel')" title="Open chat panel">
|
||||||
|
💬
|
||||||
|
</button>
|
||||||
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
<button class="theme-toggle" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
|
||||||
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
{{ theme === "dark" ? "\u2600" : "\u263E" }}
|
||||||
</button>
|
</button>
|
||||||
@@ -51,6 +59,19 @@ const { theme, toggleTheme } = useTheme();
|
|||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
.btn-chat-panel {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.btn-chat-panel:hover {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
}
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
|
import type { Message } from "@/types/chat";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
message: Message;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saveAsNote: [messageId: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const rendered = computed(() => renderMarkdown(props.message.content));
|
||||||
|
|
||||||
|
const roleLabel = computed(() => {
|
||||||
|
switch (props.message.role) {
|
||||||
|
case "user":
|
||||||
|
return "You";
|
||||||
|
case "assistant":
|
||||||
|
return "Assistant";
|
||||||
|
default:
|
||||||
|
return props.message.role;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chat-message" :class="`role-${message.role}`">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="role-label">{{ roleLabel }}</span>
|
||||||
|
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
||||||
|
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
||||||
|
Save as Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-content prose" v-html="rendered"></div>
|
||||||
|
<div
|
||||||
|
v-if="message.context_note_id"
|
||||||
|
class="context-badge"
|
||||||
|
>
|
||||||
|
<router-link :to="`/notes/${message.context_note_id}`">
|
||||||
|
Note #{{ message.context_note_id }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-message {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.role-user {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.role-assistant {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.role-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.message-content {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.message-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.message-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.btn-save {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-save:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.context-badge {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.context-badge a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.context-badge a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } 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()
|
||||||
|
);
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesEl.value) {
|
||||||
|
messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 v-if="contextNoteId" class="context-indicator">
|
||||||
|
Note #{{ contextNoteId }}
|
||||||
|
</span>
|
||||||
|
<button class="btn-close" @click="emit('close')">×</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="Type a message..."
|
||||||
|
:disabled="store.streaming"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="btn-send"
|
||||||
|
@click="sendMessage"
|
||||||
|
:disabled="!messageInput.trim() || store.streaming"
|
||||||
|
>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.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>
|
||||||
@@ -49,6 +49,16 @@ const router = createRouter({
|
|||||||
name: "task-edit",
|
name: "task-edit",
|
||||||
component: () => import("@/views/TaskEditorView.vue"),
|
component: () => import("@/views/TaskEditorView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/chat",
|
||||||
|
name: "chat",
|
||||||
|
component: () => import("@/views/ChatView.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/chat/:id",
|
||||||
|
name: "chat-conversation",
|
||||||
|
component: () => import("@/views/ChatView.vue"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export interface Message {
|
||||||
|
id: number;
|
||||||
|
conversation_id: number;
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
context_note_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
model: string;
|
||||||
|
message_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationDetail extends Conversation {
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OllamaModel {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed, watch, nextTick } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useChatStore } from "@/stores/chat";
|
||||||
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
|
import ChatMessage from "@/components/ChatMessage.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useChatStore();
|
||||||
|
|
||||||
|
const messageInput = ref("");
|
||||||
|
const messagesEl = ref<HTMLElement | null>(null);
|
||||||
|
const sending = ref(false);
|
||||||
|
const summarizing = ref(false);
|
||||||
|
|
||||||
|
const convId = computed(() => {
|
||||||
|
const id = route.params.id;
|
||||||
|
return id ? Number(id) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamingRendered = computed(() => {
|
||||||
|
if (!store.streamingContent) return "";
|
||||||
|
return renderMarkdown(store.streamingContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchConversations();
|
||||||
|
if (convId.value) {
|
||||||
|
await store.fetchConversation(convId.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(convId, async (newId) => {
|
||||||
|
if (newId) {
|
||||||
|
await store.fetchConversation(newId);
|
||||||
|
scrollToBottom();
|
||||||
|
} else {
|
||||||
|
store.currentConversation = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.streamingContent,
|
||||||
|
() => scrollToBottom()
|
||||||
|
);
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesEl.value) {
|
||||||
|
messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(id: number) {
|
||||||
|
router.push(`/chat/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newConversation() {
|
||||||
|
const conv = await store.createConversation();
|
||||||
|
router.push(`/chat/${conv.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeConversation(id: number) {
|
||||||
|
await store.deleteConversation(id);
|
||||||
|
if (convId.value === id) {
|
||||||
|
router.push("/chat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const content = messageInput.value.trim();
|
||||||
|
if (!content || store.streaming) return;
|
||||||
|
|
||||||
|
// Auto-create conversation if none selected
|
||||||
|
if (!store.currentConversation) {
|
||||||
|
const conv = await store.createConversation();
|
||||||
|
await store.fetchConversation(conv.id);
|
||||||
|
router.push(`/chat/${conv.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sending.value = true;
|
||||||
|
messageInput.value = "";
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
await store.sendMessage(content);
|
||||||
|
sending.value = false;
|
||||||
|
|
||||||
|
// Refresh conversation list to show updated title/timestamps
|
||||||
|
store.fetchConversations();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSummarize() {
|
||||||
|
if (!store.currentConversation || summarizing.value) return;
|
||||||
|
summarizing.value = true;
|
||||||
|
try {
|
||||||
|
await store.summarizeAsNote(store.currentConversation.id);
|
||||||
|
const { useToastStore } = await import("@/stores/toast");
|
||||||
|
useToastStore().show("Conversation summarized and saved as note");
|
||||||
|
} catch {
|
||||||
|
const { useToastStore } = await import("@/stores/toast");
|
||||||
|
useToastStore().show("Failed to summarize", "error");
|
||||||
|
} finally {
|
||||||
|
summarizing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="chat-page">
|
||||||
|
<aside class="chat-sidebar">
|
||||||
|
<button class="btn-new-conv" @click="newConversation">
|
||||||
|
+ New Chat
|
||||||
|
</button>
|
||||||
|
<div class="conv-list">
|
||||||
|
<div
|
||||||
|
v-for="conv in store.conversations"
|
||||||
|
:key="conv.id"
|
||||||
|
class="conv-item"
|
||||||
|
:class="{ active: convId === conv.id }"
|
||||||
|
@click="selectConversation(conv.id)"
|
||||||
|
>
|
||||||
|
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
|
||||||
|
<button
|
||||||
|
class="btn-delete-conv"
|
||||||
|
@click.stop="removeConversation(conv.id)"
|
||||||
|
title="Delete conversation"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!store.conversations.length" class="empty-msg">
|
||||||
|
No conversations yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="chat-main">
|
||||||
|
<template v-if="store.currentConversation">
|
||||||
|
<div class="chat-header">
|
||||||
|
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
|
||||||
|
<button
|
||||||
|
v-if="store.currentConversation.messages.length"
|
||||||
|
class="btn-summarize"
|
||||||
|
@click="handleSummarize"
|
||||||
|
:disabled="summarizing || store.streaming"
|
||||||
|
>
|
||||||
|
{{ summarizing ? "Summarizing..." : "Summarize as Note" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="messagesEl" class="messages-container">
|
||||||
|
<ChatMessage
|
||||||
|
v-for="msg in store.currentConversation.messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:message="msg"
|
||||||
|
@save-as-note="handleSaveAsNote"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Send a message to start the conversation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<textarea
|
||||||
|
v-model="messageInput"
|
||||||
|
@keydown="onInputKeydown"
|
||||||
|
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
|
||||||
|
:disabled="store.streaming"
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="btn-send"
|
||||||
|
@click="sendMessage"
|
||||||
|
:disabled="!messageInput.trim() || store.streaming"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="no-conversation">
|
||||||
|
<p>Select a conversation or start a new chat.</p>
|
||||||
|
<button class="btn-new-conv" @click="newConversation">
|
||||||
|
+ New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-page {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 49px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
min-width: 200px;
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-conv {
|
||||||
|
margin: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.btn-new-conv:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.conv-item:hover {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
.conv-item.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.conv-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.btn-delete-conv {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.conv-item.active .btn-delete-conv {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
.btn-delete-conv:hover {
|
||||||
|
color: var(--color-danger, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.chat-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-summarize {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-summarize:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.btn-summarize:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.input-area textarea {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.input-area textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.btn-send {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
.btn-send:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-conversation {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-msg {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.chat-sidebar {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ from quart import Quart, jsonify, make_response, request, send_from_directory
|
|||||||
|
|
||||||
from fabledassistant.config import Config
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.routes.api import api
|
from fabledassistant.routes.api import api
|
||||||
|
from fabledassistant.routes.chat import chat_bp
|
||||||
from fabledassistant.routes.notes import notes_bp
|
from fabledassistant.routes.notes import notes_bp
|
||||||
from fabledassistant.routes.tasks import tasks_bp
|
from fabledassistant.routes.tasks import tasks_bp
|
||||||
|
|
||||||
@@ -17,9 +18,23 @@ def create_app() -> Quart:
|
|||||||
app.secret_key = Config.SECRET_KEY
|
app.secret_key = Config.SECRET_KEY
|
||||||
|
|
||||||
app.register_blueprint(api)
|
app.register_blueprint(api)
|
||||||
|
app.register_blueprint(chat_bp)
|
||||||
app.register_blueprint(notes_bp)
|
app.register_blueprint(notes_bp)
|
||||||
app.register_blueprint(tasks_bp)
|
app.register_blueprint(tasks_bp)
|
||||||
|
|
||||||
|
@app.before_serving
|
||||||
|
async def startup():
|
||||||
|
from fabledassistant.services.llm import ensure_model
|
||||||
|
|
||||||
|
try:
|
||||||
|
await ensure_model(Config.OLLAMA_MODEL)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to ensure model '%s' on startup — chat may not work until model is available",
|
||||||
|
Config.OLLAMA_MODEL,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
async def serve_index():
|
async def serve_index():
|
||||||
resp = await make_response(
|
resp = await make_response(
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ class Config:
|
|||||||
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
|
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
|
||||||
)
|
)
|
||||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1")
|
||||||
SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-change-me")
|
SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-change-me")
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
|
|
||||||
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
|
||||||
|
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from fabledassistant.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(Base):
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
title: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
model: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
|
back_populates="conversation",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="Message.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"title": self.title,
|
||||||
|
"model": self.model,
|
||||||
|
"message_count": len(self.messages) if self.messages else 0,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
conversation_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("conversations.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(Text)
|
||||||
|
content: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
context_note_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_messages_conversation_id", "conversation_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"conversation_id": self.conversation_id,
|
||||||
|
"role": self.role,
|
||||||
|
"content": self.content,
|
||||||
|
"context_note_id": self.context_note_id,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from fabledassistant.config import Config
|
||||||
|
from fabledassistant.services.chat import (
|
||||||
|
add_message,
|
||||||
|
create_conversation,
|
||||||
|
delete_conversation,
|
||||||
|
get_conversation,
|
||||||
|
list_conversations,
|
||||||
|
save_response_as_note,
|
||||||
|
summarize_conversation_as_note,
|
||||||
|
update_conversation_title,
|
||||||
|
)
|
||||||
|
from fabledassistant.services.llm import build_context, stream_chat
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/conversations", methods=["GET"])
|
||||||
|
async def list_conversations_route():
|
||||||
|
limit = request.args.get("limit", 50, type=int)
|
||||||
|
offset = request.args.get("offset", 0, type=int)
|
||||||
|
conversations, total = await list_conversations(limit=limit, offset=offset)
|
||||||
|
return jsonify({
|
||||||
|
"conversations": [c.to_dict() for c in conversations],
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/conversations", methods=["POST"])
|
||||||
|
async def create_conversation_route():
|
||||||
|
data = await request.get_json(force=True, silent=True) or {}
|
||||||
|
title = data.get("title", "")
|
||||||
|
model = data.get("model", Config.OLLAMA_MODEL)
|
||||||
|
conv = await create_conversation(title=title, model=model)
|
||||||
|
return jsonify(conv.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
|
||||||
|
async def get_conversation_route(conv_id: int):
|
||||||
|
conv = await get_conversation(conv_id)
|
||||||
|
if conv is None:
|
||||||
|
return jsonify({"error": "Conversation not found"}), 404
|
||||||
|
result = conv.to_dict()
|
||||||
|
result["messages"] = [m.to_dict() for m in conv.messages]
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"])
|
||||||
|
async def delete_conversation_route(conv_id: int):
|
||||||
|
deleted = await delete_conversation(conv_id)
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({"error": "Conversation not found"}), 404
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
|
||||||
|
async def update_conversation_route(conv_id: int):
|
||||||
|
data = await request.get_json()
|
||||||
|
title = data.get("title")
|
||||||
|
if title is None:
|
||||||
|
return jsonify({"error": "title is required"}), 400
|
||||||
|
conv = await update_conversation_title(conv_id, title)
|
||||||
|
if conv is None:
|
||||||
|
return jsonify({"error": "Conversation not found"}), 404
|
||||||
|
return jsonify(conv.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"])
|
||||||
|
async def send_message_route(conv_id: int):
|
||||||
|
"""Send a user message, stream the LLM response via SSE."""
|
||||||
|
conv = await get_conversation(conv_id)
|
||||||
|
if conv is None:
|
||||||
|
return jsonify({"error": "Conversation not found"}), 404
|
||||||
|
|
||||||
|
data = await request.get_json()
|
||||||
|
content = data.get("content", "").strip()
|
||||||
|
if not content:
|
||||||
|
return jsonify({"error": "content is required"}), 400
|
||||||
|
context_note_id = data.get("context_note_id")
|
||||||
|
|
||||||
|
# Save user message
|
||||||
|
await add_message(conv_id, "user", content, context_note_id=context_note_id)
|
||||||
|
|
||||||
|
# Build history from existing messages (excluding system)
|
||||||
|
history = []
|
||||||
|
for msg in conv.messages:
|
||||||
|
if msg.role != "system":
|
||||||
|
history.append({"role": msg.role, "content": msg.content})
|
||||||
|
|
||||||
|
# Build context with note search, URL fetching, etc.
|
||||||
|
messages = await build_context(history, context_note_id, content)
|
||||||
|
|
||||||
|
model = conv.model or Config.OLLAMA_MODEL
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
full_response = []
|
||||||
|
try:
|
||||||
|
async for chunk in stream_chat(messages, model):
|
||||||
|
full_response.append(chunk)
|
||||||
|
event_data = json.dumps({"chunk": chunk})
|
||||||
|
yield f"data: {event_data}\n\n"
|
||||||
|
|
||||||
|
# Save complete assistant message
|
||||||
|
full_text = "".join(full_response)
|
||||||
|
msg = await add_message(conv_id, "assistant", full_text)
|
||||||
|
|
||||||
|
# Auto-generate title from first user message if conversation has no title
|
||||||
|
if not conv.title:
|
||||||
|
title = content[:80]
|
||||||
|
if len(content) > 80:
|
||||||
|
title += "..."
|
||||||
|
await update_conversation_title(conv_id, title)
|
||||||
|
|
||||||
|
done_data = json.dumps({"done": True, "message_id": msg.id})
|
||||||
|
yield f"data: {done_data}\n\n"
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error streaming LLM response")
|
||||||
|
error_data = json.dumps({"error": str(e)})
|
||||||
|
yield f"data: {error_data}\n\n"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate(),
|
||||||
|
content_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"])
|
||||||
|
async def save_message_as_note_route(message_id: int):
|
||||||
|
try:
|
||||||
|
note = await save_response_as_note(message_id)
|
||||||
|
return jsonify(note), 201
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"])
|
||||||
|
async def summarize_conversation_route(conv_id: int):
|
||||||
|
conv = await get_conversation(conv_id)
|
||||||
|
if conv is None:
|
||||||
|
return jsonify({"error": "Conversation not found"}), 404
|
||||||
|
model = conv.model or Config.OLLAMA_MODEL
|
||||||
|
try:
|
||||||
|
note = await summarize_conversation_as_note(conv_id, model)
|
||||||
|
return jsonify(note), 201
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route("/models", methods=["GET"])
|
||||||
|
async def list_models_route():
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
models = [
|
||||||
|
{"name": m["name"], "size": m.get("size", 0)}
|
||||||
|
for m in data.get("models", [])
|
||||||
|
]
|
||||||
|
return jsonify({"models": models})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to list Ollama models: %s", e)
|
||||||
|
return jsonify({"models": [], "error": str(e)}), 200
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from fabledassistant.models import async_session
|
||||||
|
from fabledassistant.models.conversation import Conversation, Message
|
||||||
|
from fabledassistant.services.llm import generate_completion
|
||||||
|
from fabledassistant.services.notes import create_note
|
||||||
|
|
||||||
|
|
||||||
|
async def create_conversation(title: str = "", model: str = "") -> Conversation:
|
||||||
|
async with async_session() as session:
|
||||||
|
conv = Conversation(title=title, model=model)
|
||||||
|
session.add(conv)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(conv)
|
||||||
|
# Initialize empty messages list for to_dict()
|
||||||
|
conv.messages = []
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
async def get_conversation(conversation_id: int) -> Conversation | None:
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.options(selectinload(Conversation.messages))
|
||||||
|
.where(Conversation.id == conversation_id)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_conversations(
|
||||||
|
limit: int = 50, offset: int = 0
|
||||||
|
) -> tuple[list[Conversation], int]:
|
||||||
|
async with async_session() as session:
|
||||||
|
total = await session.scalar(
|
||||||
|
select(func.count(Conversation.id))
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.options(selectinload(Conversation.messages))
|
||||||
|
.order_by(Conversation.updated_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
conversations = list(result.scalars().all())
|
||||||
|
return conversations, total
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_conversation(conversation_id: int) -> bool:
|
||||||
|
async with async_session() as session:
|
||||||
|
conv = await session.get(Conversation, conversation_id)
|
||||||
|
if conv is None:
|
||||||
|
return False
|
||||||
|
await session.delete(conv)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def update_conversation_title(
|
||||||
|
conversation_id: int, title: str
|
||||||
|
) -> Conversation | None:
|
||||||
|
async with async_session() as session:
|
||||||
|
conv = await session.get(Conversation, conversation_id)
|
||||||
|
if conv is None:
|
||||||
|
return None
|
||||||
|
conv.title = title
|
||||||
|
conv.updated_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(conv)
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
async def add_message(
|
||||||
|
conversation_id: int,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
context_note_id: int | None = None,
|
||||||
|
) -> Message:
|
||||||
|
async with async_session() as session:
|
||||||
|
msg = Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
context_note_id=context_note_id,
|
||||||
|
)
|
||||||
|
session.add(msg)
|
||||||
|
# Touch conversation updated_at
|
||||||
|
conv = await session.get(Conversation, conversation_id)
|
||||||
|
if conv:
|
||||||
|
conv.updated_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(msg)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
async def get_message(message_id: int) -> Message | None:
|
||||||
|
async with async_session() as session:
|
||||||
|
return await session.get(Message, message_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def save_response_as_note(message_id: int) -> dict:
|
||||||
|
"""Create a note from an assistant message. Returns the new note dict."""
|
||||||
|
msg = await get_message(message_id)
|
||||||
|
if msg is None:
|
||||||
|
raise ValueError("Message not found")
|
||||||
|
if msg.role != "assistant":
|
||||||
|
raise ValueError("Can only save assistant messages as notes")
|
||||||
|
|
||||||
|
# Use first line as title, rest as body
|
||||||
|
lines = msg.content.strip().split("\n", 1)
|
||||||
|
title = lines[0].strip().lstrip("# ")[:100]
|
||||||
|
body = msg.content
|
||||||
|
|
||||||
|
note = await create_note(title=title, body=body)
|
||||||
|
return note.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def summarize_conversation_as_note(
|
||||||
|
conversation_id: int, model: str
|
||||||
|
) -> dict:
|
||||||
|
"""Summarize a conversation using the LLM and save as a note."""
|
||||||
|
conv = await get_conversation(conversation_id)
|
||||||
|
if conv is None:
|
||||||
|
raise ValueError("Conversation not found")
|
||||||
|
|
||||||
|
# Build the conversation text
|
||||||
|
conv_text = []
|
||||||
|
for msg in conv.messages:
|
||||||
|
if msg.role == "system":
|
||||||
|
continue
|
||||||
|
label = "User" if msg.role == "user" else "Assistant"
|
||||||
|
conv_text.append(f"{label}: {msg.content}")
|
||||||
|
|
||||||
|
prompt_messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Summarize the following conversation into a concise note. "
|
||||||
|
"Include key points, decisions, and any action items. "
|
||||||
|
"Format the summary in markdown."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": "\n\n".join(conv_text)},
|
||||||
|
]
|
||||||
|
|
||||||
|
summary = await generate_completion(prompt_messages, model)
|
||||||
|
|
||||||
|
title = conv.title or "Conversation Summary"
|
||||||
|
title = f"Summary: {title}"
|
||||||
|
|
||||||
|
note = await create_note(title=title, body=summary)
|
||||||
|
return note.to_dict()
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from fabledassistant.config import Config
|
||||||
|
from fabledassistant.services.notes import get_note, list_notes
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STOP_WORDS = frozenset({
|
||||||
|
"a", "an", "the", "is", "it", "to", "in", "for", "of", "and", "or",
|
||||||
|
"on", "at", "by", "with", "from", "as", "be", "was", "were", "been",
|
||||||
|
"are", "am", "do", "does", "did", "have", "has", "had", "will", "would",
|
||||||
|
"can", "could", "shall", "should", "may", "might", "must", "that",
|
||||||
|
"this", "these", "those", "i", "me", "my", "you", "your", "he", "she",
|
||||||
|
"we", "they", "them", "his", "her", "its", "our", "their", "what",
|
||||||
|
"which", "who", "whom", "how", "when", "where", "why", "not", "no",
|
||||||
|
"but", "if", "so", "than", "too", "very", "just", "about", "up",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_model(model: str) -> None:
|
||||||
|
"""Check if model exists in Ollama, pull if missing."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
names = {m["name"] for m in data.get("models", [])}
|
||||||
|
# Check both with and without :latest tag
|
||||||
|
if model in names or f"{model}:latest" in names:
|
||||||
|
logger.info("Model '%s' already available", model)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to check Ollama models, attempting pull anyway")
|
||||||
|
|
||||||
|
logger.info("Pulling model '%s' from Ollama...", model)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=600.0) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
f"{Config.OLLAMA_URL}/api/pull",
|
||||||
|
json={"name": model},
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if line.strip():
|
||||||
|
status = json.loads(line)
|
||||||
|
if "status" in status:
|
||||||
|
logger.info("Pull %s: %s", model, status["status"])
|
||||||
|
logger.info("Model '%s' pulled successfully", model)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to pull model '%s' — chat may not work", model, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_chat(
|
||||||
|
messages: list[dict], model: str
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Stream chat completion from Ollama, yielding content chunks."""
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
f"{Config.OLLAMA_URL}/api/chat",
|
||||||
|
json={"model": model, "messages": messages, "stream": True},
|
||||||
|
) as resp:
|
||||||
|
resp.raise_for_status()
|
||||||
|
async for line in resp.aiter_lines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
data = json.loads(line)
|
||||||
|
chunk = data.get("message", {}).get("content", "")
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
if data.get("done"):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_completion(messages: list[dict], model: str) -> str:
|
||||||
|
"""Non-streaming chat completion, returns full response text."""
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{Config.OLLAMA_URL}/api/chat",
|
||||||
|
json={"model": model, "messages": messages, "stream": False},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_url_content(url: str) -> str:
|
||||||
|
"""Fetch a URL and return text content (HTML tags stripped)."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=15.0, follow_redirects=True, headers={"User-Agent": "FabledAssistant/1.0"}
|
||||||
|
) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
text = resp.text
|
||||||
|
# Strip HTML tags
|
||||||
|
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r"<[^>]+>", " ", text)
|
||||||
|
# Collapse whitespace
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
# Truncate to reasonable size
|
||||||
|
if len(text) > 4000:
|
||||||
|
text = text[:4000] + "..."
|
||||||
|
return text
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to fetch URL %s: %s", url, e)
|
||||||
|
return f"[Failed to fetch URL: {url}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_keywords(text: str) -> list[str]:
|
||||||
|
"""Extract meaningful keywords from text for note search."""
|
||||||
|
words = re.findall(r"\b[a-zA-Z]{3,}\b", text.lower())
|
||||||
|
keywords = [w for w in words if w not in STOP_WORDS]
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique = []
|
||||||
|
for w in keywords:
|
||||||
|
if w not in seen:
|
||||||
|
seen.add(w)
|
||||||
|
unique.append(w)
|
||||||
|
return unique[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_urls(text: str) -> list[str]:
|
||||||
|
"""Find URLs in text."""
|
||||||
|
return re.findall(r"https?://[^\s<>\"')\]]+", text)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_context(
|
||||||
|
history: list[dict],
|
||||||
|
current_note_id: int | None,
|
||||||
|
user_message: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Build messages array for Ollama with system prompt and context."""
|
||||||
|
system_parts = [
|
||||||
|
"You are a helpful assistant integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
||||||
|
"Help users with their notes, tasks, and general questions. "
|
||||||
|
"When note context is provided, use it to give relevant answers."
|
||||||
|
]
|
||||||
|
|
||||||
|
# Include current note context if provided
|
||||||
|
if current_note_id:
|
||||||
|
note = await get_note(current_note_id)
|
||||||
|
if note:
|
||||||
|
system_parts.append(
|
||||||
|
f"\n\n--- Current Note ---\n"
|
||||||
|
f"Title: {note.title}\n"
|
||||||
|
f"Content:\n{note.body}\n"
|
||||||
|
f"--- End Note ---"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Search notes by keywords from user message
|
||||||
|
keywords = _extract_keywords(user_message)
|
||||||
|
if keywords:
|
||||||
|
search_q = " ".join(keywords[:3])
|
||||||
|
try:
|
||||||
|
notes, _ = await list_notes(q=search_q, limit=3)
|
||||||
|
if notes:
|
||||||
|
snippets = []
|
||||||
|
for n in notes:
|
||||||
|
# Skip the current note (already included)
|
||||||
|
if current_note_id and n.id == current_note_id:
|
||||||
|
continue
|
||||||
|
body_preview = n.body[:300] if n.body else ""
|
||||||
|
snippets.append(f"- {n.title}: {body_preview}")
|
||||||
|
if snippets:
|
||||||
|
system_parts.append(
|
||||||
|
"\n\n--- Related Notes ---\n"
|
||||||
|
+ "\n".join(snippets)
|
||||||
|
+ "\n--- End Related Notes ---"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fetch URL content from user message
|
||||||
|
urls = _find_urls(user_message)
|
||||||
|
for url in urls[:2]: # Limit to 2 URLs
|
||||||
|
content = await fetch_url_content(url)
|
||||||
|
if content and not content.startswith("[Failed"):
|
||||||
|
system_parts.append(
|
||||||
|
f"\n\n--- Content from {url} ---\n{content}\n--- End URL Content ---"
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [{"role": "system", "content": "".join(system_parts)}]
|
||||||
|
messages.extend(history)
|
||||||
|
messages.append({"role": "user", "content": user_message})
|
||||||
|
return messages
|
||||||
+74
-34
@@ -12,7 +12,7 @@
|
|||||||
> Include file-level details in the commit body when the change is non-trivial.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
2026-02-10 — Phase 3.6 complete (Merged tasks into notes — a task is just a note with task attributes)
|
2026-02-10 — Phase 4 complete (LLM Chat Integration — streaming chat with Ollama, note-aware context, save/summarize as note)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||||
@@ -56,6 +56,15 @@ for AI-assisted features.
|
|||||||
- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
|
- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
|
||||||
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
|
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
|
||||||
missing notes.
|
missing notes.
|
||||||
|
- **SSE over WebSockets for LLM streaming:** Uses `POST` with `text/event-stream`
|
||||||
|
response (not EventSource). Frontend uses `fetch()` + `ReadableStream` since we
|
||||||
|
need to POST the message body. Simpler than WebSockets; Quart supports async
|
||||||
|
generators natively for SSE.
|
||||||
|
- **Context building server-side:** Backend fetches URL content and searches notes —
|
||||||
|
frontend just sends the message text + optional note ID. Keyword extraction uses
|
||||||
|
simple word splitting with stopword filtering (no embeddings).
|
||||||
|
- **Auto-pull model on startup:** Non-blocking, logs warning on failure so the app
|
||||||
|
still starts even if Ollama isn't ready.
|
||||||
- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for
|
- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for
|
||||||
`[[Title]]` patterns referencing the given note. Results include `type: "note"` or
|
`[[Title]]` patterns referencing the given note. Results include `type: "note"` or
|
||||||
`type: "task"` based on whether the linking note has `status IS NOT NULL`.
|
`type: "task"` based on whether the linking note has `status IS NOT NULL`.
|
||||||
@@ -114,9 +123,18 @@ for AI-assisted features.
|
|||||||
- "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)`
|
- "Convert to note" = `update_note(id, status=None, priority=None, due_date=None)`
|
||||||
- Task body field is `body` (not `description`) — standardized with notes
|
- Task body field is `body` (not `description`) — standardized with notes
|
||||||
|
|
||||||
### LLM Interactions (Phase 4)
|
### Conversations
|
||||||
- Summarize notes, generate task breakdowns, search/query across notes,
|
- `id` (int PK), `title` (str), `model` (str), `created_at`, `updated_at`
|
||||||
chat-style assistant within the app
|
- Has many messages (cascade delete)
|
||||||
|
- Title auto-generated from first user message if not set
|
||||||
|
- `to_dict()` returns: `id`, `title`, `model`, `message_count`, `created_at`, `updated_at`
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
- `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant),
|
||||||
|
`content` (str), `context_note_id` (nullable FK to notes, SET NULL), `created_at`
|
||||||
|
- Index on `conversation_id`
|
||||||
|
- `context_note_id` tracks which note was attached as context when message was sent
|
||||||
|
- `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `context_note_id`, `created_at`
|
||||||
|
|
||||||
## Project Structure (Current)
|
## Project Structure (Current)
|
||||||
```
|
```
|
||||||
@@ -132,23 +150,28 @@ fabledassistant/
|
|||||||
│ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent)
|
│ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent)
|
||||||
│ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent)
|
│ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent)
|
||||||
│ ├── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks
|
│ ├── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks
|
||||||
│ └── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table
|
│ ├── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table
|
||||||
|
│ └── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes
|
||||||
├── src/
|
├── src/
|
||||||
│ └── fabledassistant/
|
│ └── fabledassistant/
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API
|
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API
|
||||||
│ ├── config.py # Config from env vars
|
│ ├── config.py # Config from env vars
|
||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority
|
│ │ ├── __init__.py # async_session factory, Base, imports Note + TaskStatus + TaskPriority + Conversation + Message
|
||||||
│ │ └── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
|
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
|
||||||
|
│ │ └── conversation.py # Conversation + Message models with relationships and to_dict()
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ ├── api.py # /api blueprint with /health endpoint
|
│ │ ├── api.py # /api blueprint with /health endpoint
|
||||||
|
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list
|
||||||
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
|
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
|
||||||
│ │ └── tasks.py # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description)
|
│ │ └── tasks.py # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description)
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks
|
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks
|
||||||
│ │ └── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
|
│ │ ├── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
|
||||||
|
│ │ ├── llm.py # Ollama interaction: ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, note search, URL fetching)
|
||||||
|
│ │ └── chat.py # Conversation CRUD, add_message, save_response_as_note, summarize_conversation_as_note
|
||||||
│ ├── utils/
|
│ ├── utils/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
|
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
|
||||||
@@ -158,26 +181,29 @@ fabledassistant/
|
|||||||
├── vite.config.ts
|
├── vite.config.ts
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── App.vue # Shell: AppHeader + router-view + ToastNotification
|
│ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification
|
||||||
│ ├── main.ts # App init, imports theme.css
|
│ ├── main.ts # App init, imports theme.css
|
||||||
│ ├── assets/
|
│ ├── assets/
|
||||||
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset
|
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete helpers
|
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete + apiStreamPost (SSE via fetch + ReadableStream)
|
||||||
│ ├── composables/
|
│ ├── composables/
|
||||||
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
|
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
|
||||||
│ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
|
│ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
|
||||||
│ ├── stores/
|
│ ├── stores/
|
||||||
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
|
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
|
||||||
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
|
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
|
||||||
|
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), saveMessageAsNote, summarizeAsNote, fetchModels
|
||||||
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
|
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
|
||||||
│ ├── types/
|
│ ├── types/
|
||||||
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
|
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
|
||||||
|
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, OllamaModel interfaces
|
||||||
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
|
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
|
||||||
│ ├── utils/
|
│ ├── utils/
|
||||||
│ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
|
│ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
|
||||||
│ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards)
|
│ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards)
|
||||||
│ ├── views/
|
│ ├── views/
|
||||||
|
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar (conversation list) + message thread + input + summarize
|
||||||
│ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling)
|
│ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling)
|
||||||
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
|
||||||
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
|
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
|
||||||
@@ -186,7 +212,9 @@ fabledassistant/
|
|||||||
│ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete
|
│ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete
|
||||||
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
|
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle
|
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks + Chat links, chat panel toggle, theme toggle
|
||||||
|
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop)
|
||||||
|
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, role label, "Save as Note" action on assistant messages
|
||||||
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
|
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
|
||||||
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags
|
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags
|
||||||
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
|
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
|
||||||
@@ -197,7 +225,7 @@ fabledassistant/
|
|||||||
│ │ ├── PaginationBar.vue # Prev/next + page numbers
|
│ │ ├── PaginationBar.vue # Prev/next + page numbers
|
||||||
│ │ └── ToastNotification.vue # Fixed-position toast container
|
│ │ └── ToastNotification.vue # Fixed-position toast container
|
||||||
│ └── router/
|
│ └── router/
|
||||||
│ └── index.ts # Routes: /, /notes/*, /tasks/*
|
│ └── index.ts # Routes: /, /notes/*, /tasks/*, /chat, /chat/:id
|
||||||
└── public/
|
└── public/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -223,6 +251,15 @@ fabledassistant/
|
|||||||
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) |
|
| PUT | `/api/tasks/:id` | Update task (accepts `body` or `description`, prefers `body`) |
|
||||||
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
|
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
|
||||||
| DELETE | `/api/tasks/:id` | Delete task (simple delete) |
|
| DELETE | `/api/tasks/:id` | Delete task (simple delete) |
|
||||||
|
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
|
||||||
|
| POST | `/api/chat/conversations` | Create conversation (body: `{title?, model?}`) |
|
||||||
|
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
||||||
|
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
||||||
|
| PATCH | `/api/chat/conversations/:id` | Update conversation title (body: `{title}`) |
|
||||||
|
| POST | `/api/chat/conversations/:id/messages` | Send message + stream LLM response via SSE (body: `{content, context_note_id?}`) |
|
||||||
|
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note |
|
||||||
|
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note |
|
||||||
|
| GET | `/api/chat/models` | List available Ollama models |
|
||||||
|
|
||||||
## Alembic Migrations
|
## Alembic Migrations
|
||||||
|
|
||||||
@@ -233,7 +270,7 @@ container startup.
|
|||||||
|
|
||||||
### Migration Chain
|
### Migration Chain
|
||||||
```
|
```
|
||||||
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py
|
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py → 0004_merge_tasks_into_notes.py → 0005_add_chat_tables.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### How Migrations Run
|
### How Migrations Run
|
||||||
@@ -363,12 +400,18 @@ When adding a new migration, follow these conventions:
|
|||||||
- [x] **Frontend unified types:** `Task` is a re-export alias for `Note`; `note.ts` defines `TaskStatus`, `TaskPriority`
|
- [x] **Frontend unified types:** `Task` is a re-export alias for `Note`; `note.ts` defines `TaskStatus`, `TaskPriority`
|
||||||
- [x] **Simplified views:** Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView
|
- [x] **Simplified views:** Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView
|
||||||
|
|
||||||
### Phase 4 — LLM Integration (next)
|
### Phase 4 — LLM Chat Integration ✓
|
||||||
- [ ] Ollama client in the backend (async HTTP via httpx/aiohttp)
|
- [x] **Ollama client:** async HTTP via httpx — `ensure_model()` (auto-pull on startup), `stream_chat()` (streaming NDJSON), `generate_completion()` (non-streaming)
|
||||||
- [ ] API endpoints for LLM features (summarize note, generate tasks from note,
|
- [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars)
|
||||||
freeform chat)
|
- [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD
|
||||||
- [ ] Vue components for LLM interactions (chat panel, summarize button, etc.)
|
- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish
|
||||||
- [ ] Configurable LLM endpoint + model selection in app settings
|
- [x] **Save as note:** Save any assistant message as a new note (first line as title)
|
||||||
|
- [x] **Summarize conversation:** Send full conversation to LLM with summarize prompt, save result as note
|
||||||
|
- [x] **Model management:** `GET /api/chat/models` lists available Ollama models; auto-pull default model (`llama3.1`) on app startup
|
||||||
|
- [x] **Dedicated chat page:** `/chat` with sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator
|
||||||
|
- [x] **Slide-out chat panel:** Toggle from header, receives `contextNoteId` from current route (`/notes/:id` or `/tasks/:id`), auto-creates conversation on first message
|
||||||
|
- [x] **Frontend SSE client:** `apiStreamPost()` uses fetch + ReadableStream to parse SSE data lines
|
||||||
|
- [x] **Auto-title:** Conversation title auto-set from first user message content
|
||||||
|
|
||||||
### Phase 5 — Polish & Production Hardening
|
### Phase 5 — Polish & Production Hardening
|
||||||
- [ ] Authentication (single-user or multi-user, TBD)
|
- [ ] Authentication (single-user or multi-user, TBD)
|
||||||
@@ -378,7 +421,6 @@ When adding a new migration, follow these conventions:
|
|||||||
- [ ] Error handling, logging, monitoring
|
- [ ] Error handling, logging, monitoring
|
||||||
|
|
||||||
### Future / Stretch
|
### Future / Stretch
|
||||||
- WebSocket streaming for LLM responses
|
|
||||||
- Tagging/labeling system with LLM-suggested tags
|
- Tagging/labeling system with LLM-suggested tags
|
||||||
- Calendar/timeline view for tasks
|
- Calendar/timeline view for tasks
|
||||||
- Import/export (Markdown files, JSON)
|
- Import/export (Markdown files, JSON)
|
||||||
@@ -394,19 +436,17 @@ When adding a new migration, follow these conventions:
|
|||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
- Authentication model: single-user (password-only) vs multi-user?
|
- Authentication model: single-user (password-only) vs multi-user?
|
||||||
- Should LLM streaming use WebSockets from Phase 4 or defer to Phase 5?
|
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
**Phase:** Phase 3.6 complete. Tasks merged into notes — unified single-table model.
|
**Phase:** Phase 4 complete. LLM chat integration with streaming responses.
|
||||||
- Single `notes` table with optional `status`, `priority`, `due_date` columns
|
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
|
||||||
- A note **is a task** when `status IS NOT NULL`
|
- Unified note/task model (a task is a note with `status IS NOT NULL`)
|
||||||
- Convert between note ↔ task by setting/clearing task attributes (same ID preserved)
|
- LLM chat via Ollama with SSE streaming responses
|
||||||
- No companion notes, no cascade deletes, no bidirectional sync
|
- Note-aware context: auto-includes current note + searches related notes by keyword
|
||||||
- Task body standardized as `body` (not `description`)
|
- URL content fetching: detects URLs in messages, fetches and includes content
|
||||||
- `services/tasks.py` is a thin wrapper around `services/notes.py`
|
- Save assistant messages as notes; summarize entire conversations as notes
|
||||||
- Frontend `Task` type is an alias for `Note`
|
- Dedicated `/chat` page with conversation sidebar + message thread
|
||||||
- Full notes CRUD with markdown editing, rendering, and wikilinks
|
- Slide-out chat panel accessible from any page, auto-includes note/task context
|
||||||
- Full tasks CRUD with status/priority enums and filters
|
- Auto-pull default model (`llama3.1`) on startup
|
||||||
- Backlinks, wikilink auto-create, tag autocomplete all work across unified model
|
- Dark/light theme with CSS custom properties
|
||||||
- Dark/light theme with status/priority/wikilink color variables
|
- Ready for Phase 5: Polish & Production Hardening
|
||||||
- Ready for Phase 4: LLM Integration
|
|
||||||
|
|||||||
Reference in New Issue
Block a user