Add note context visibility in chat and standardize UI design tokens

Improve chat context: build_context() now returns metadata about auto-found
notes, emitted as an SSE event so the frontend can display context pills
showing which notes influenced the response. Users can promote notes for
deeper context (+) or exclude irrelevant ones (x). A note picker lets users
manually attach notes. Multi-word search uses per-term AND matching, and
auto-search iterates keywords individually for broader OR-style coverage.

Standardize styling: introduce CSS design tokens (--radius-sm/md/lg/pill,
--color-success/warning/overlay, --focus-ring) and migrate all components
to use them. Fix header alignment to full-width, add active nav link state,
replace hardcoded colors with CSS variables, and normalize button padding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 06:49:12 -05:00
parent 39bcd7a8fa
commit fb18d2c41d
26 changed files with 1070 additions and 237 deletions
+25
View File
@@ -32,6 +32,17 @@
--color-code-bg: #f6f8fa;
--color-code-inline-bg: #eff1f3;
--color-table-stripe: #f9f9f9;
--color-success: #22c55e;
--color-warning: #eab308;
--color-input-bar-bg: #f0f0f0;
--color-input-bar-text: #1a1a1a;
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
--color-overlay: rgba(0, 0, 0, 0.45);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
}
[data-theme="dark"] {
@@ -68,6 +79,12 @@
--color-code-bg: #161b22;
--color-code-inline-bg: #2a3040;
--color-table-stripe: #1a2030;
--color-success: #4ade80;
--color-warning: #facc15;
--color-input-bar-bg: #1c1c1e;
--color-input-bar-text: #ffffff;
--color-input-bar-placeholder: rgba(255, 255, 255, 0.4);
--color-overlay: rgba(0, 0, 0, 0.6);
}
*,
@@ -85,3 +102,11 @@ body {
line-height: 1.5;
transition: background-color 0.2s, color 0.2s;
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
button:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
+19 -10
View File
@@ -54,9 +54,7 @@ const statusClass = computed(() => {
border-bottom: 1px solid var(--color-border);
}
.nav {
max-width: 960px;
margin: 0 auto;
padding: 0.5rem 1rem;
padding: 0.5rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
@@ -70,19 +68,29 @@ const statusClass = computed(() => {
.nav-links {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.25rem;
}
.nav-link {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.95rem;
font-size: 0.9rem;
padding: 0.3rem 0.6rem;
border-radius: var(--radius-sm);
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--color-primary);
background: var(--color-bg-card);
}
.nav-link.router-link-active {
color: var(--color-primary);
background: var(--color-bg-card);
font-weight: 600;
}
.status-indicator {
display: flex;
align-items: center;
margin-left: 0.5rem;
}
.status-dot {
width: 8px;
@@ -91,14 +99,14 @@ const statusClass = computed(() => {
flex-shrink: 0;
}
.status-green .status-dot {
background: #22c55e;
background: var(--color-success);
}
.status-yellow .status-dot {
background: #eab308;
background: var(--color-warning);
animation: pulse-dot 2s infinite;
}
.status-red .status-dot {
background: #ef4444;
background: var(--color-danger);
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
@@ -107,12 +115,13 @@ const statusClass = computed(() => {
.btn-chat-panel {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 1rem;
color: var(--color-text);
line-height: 1;
margin-left: 0.25rem;
}
.btn-chat-panel:hover {
background: var(--color-bg-card);
@@ -120,7 +129,7 @@ const statusClass = computed(() => {
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 1.1rem;
+38 -18
View File
@@ -17,6 +17,12 @@ const emit = defineEmits<{
const rendered = computed(() => renderMarkdown(props.message.content));
const formattedTime = computed(() => {
if (!props.message.created_at) return "";
const date = new Date(props.message.created_at);
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
});
const roleLabel = computed(() => {
switch (props.message.role) {
case "user":
@@ -31,24 +37,27 @@ const roleLabel = computed(() => {
<template>
<div class="chat-message" :class="`role-${message.role}`">
<div class="message-bubble">
<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 class="message-group">
<div class="message-bubble">
<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>
<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>
<span v-if="formattedTime" class="message-timestamp">{{ formattedTime }}</span>
</div>
</div>
</template>
@@ -65,8 +74,11 @@ const roleLabel = computed(() => {
justify-content: flex-start;
}
.message-bubble {
.message-group {
max-width: 80%;
}
.message-bubble {
padding: 0.75rem 1rem;
border-radius: 16px;
}
@@ -136,7 +148,7 @@ const roleLabel = computed(() => {
padding: 0.1rem 0.4rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
cursor: pointer;
}
@@ -159,4 +171,12 @@ const roleLabel = computed(() => {
.context-badge a:hover {
text-decoration: underline;
}
.message-timestamp {
display: block;
font-size: 0.7rem;
color: var(--color-text-muted);
margin-top: 0.15rem;
padding: 0 0.5rem;
}
</style>
+377 -48
View File
@@ -2,8 +2,10 @@
import { ref, computed, watch, nextTick } from "vue";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
import type { Note } from "@/types/note";
const props = defineProps<{
contextNoteId?: number | null;
@@ -17,6 +19,18 @@ const store = useChatStore();
const settingsStore = useSettingsStore();
const messageInput = ref("");
const messagesEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
// Note picker state
const attachedNote = ref<{ id: number; title: string } | null>(null);
const showNotePicker = ref(false);
const noteSearchQuery = ref("");
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Exclude tracking
const excludedNoteIds = ref<Set<number>>(new Set());
const streamingRendered = computed(() => {
if (!store.streamingContent) return "";
@@ -46,9 +60,16 @@ async function sendMessage() {
await store.fetchConversation(conv.id);
}
const contextNoteId = attachedNote.value?.id ?? props.contextNoteId;
attachedNote.value = null;
messageInput.value = "";
resetTextareaHeight();
scrollToBottom();
await store.sendMessage(content, props.contextNoteId);
await store.sendMessage(
content,
contextNoteId,
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
);
scrollToBottom();
}
@@ -63,12 +84,77 @@ async function handleSaveAsNote(messageId: number) {
}
}
function autoResize() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 150) + "px";
}
function resetTextareaHeight() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
// Note picker
function toggleNotePicker() {
showNotePicker.value = !showNotePicker.value;
if (showNotePicker.value) {
noteSearchQuery.value = "";
noteSearchResults.value = [];
nextTick(() => {
const el = document.querySelector(".panel-note-picker-search") as HTMLInputElement;
el?.focus();
});
}
}
function onNoteSearchInput() {
if (noteSearchTimer) clearTimeout(noteSearchTimer);
noteSearchTimer = setTimeout(async () => {
const q = noteSearchQuery.value.trim();
if (!q) {
noteSearchResults.value = [];
return;
}
noteSearchLoading.value = true;
try {
const data = await apiGet<{ notes: Note[] }>(
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
);
noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }));
} catch {
noteSearchResults.value = [];
} finally {
noteSearchLoading.value = false;
}
}, 250);
}
function selectNote(note: { id: number; title: string }) {
attachedNote.value = note;
showNotePicker.value = false;
}
function removeAttachedNote() {
attachedNote.value = null;
}
function promoteAutoNote(note: { id: number; title: string }) {
attachedNote.value = note;
}
function excludeAutoNote(noteId: number) {
excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]);
}
</script>
<template>
@@ -83,49 +169,126 @@ function onInputKeydown(e: KeyboardEvent) {
</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 class="messages-inner">
<template v-if="store.currentConversation">
<ChatMessage
v-for="msg in store.currentConversation.messages"
:key="msg.id"
:message="msg"
@save-as-note="handleSaveAsNote"
/>
</template>
<!-- Streaming bubble -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span class="typing-indicator"></span>
<!-- Context pills -->
<div
v-if="store.lastContextMeta?.auto_notes?.length && (store.streaming || store.currentConversation?.messages.length)"
class="context-pills"
>
<span class="context-pills-label">Context:</span>
<span
v-for="note in store.lastContextMeta.auto_notes"
:key="note.id"
class="context-pill"
>
<router-link :to="`/notes/${note.id}`" class="context-pill-link" @click="emit('close')">
{{ note.title }}
</router-link>
<button
class="context-pill-btn promote"
@click="promoteAutoNote(note)"
title="Attach for next message"
>+</button>
<button
class="context-pill-btn exclude"
@click="excludeAutoNote(note.id)"
title="Exclude from auto-search"
>&times;</button>
</span>
</div>
</div>
<p
v-if="!store.currentConversation?.messages.length && !store.streaming"
class="empty-msg"
>
Start chatting...
</p>
<!-- Streaming bubble -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span class="typing-indicator"></span>
</div>
</div>
<p
v-if="!store.currentConversation?.messages.length && !store.streaming"
class="empty-msg"
>
Start chatting...
</p>
</div>
</div>
<div class="panel-input">
<textarea
v-model="messageInput"
@keydown="onInputKeydown"
:placeholder="store.chatReady ? 'Type a message...' : 'Chat unavailable'"
:disabled="store.streaming || !store.chatReady"
rows="2"
></textarea>
<button
class="btn-send"
@click="sendMessage"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
>
&uarr;
</button>
<div class="panel-input-wrapper">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" @click="removeAttachedNote">&times;</button>
</span>
</div>
<div class="panel-input">
<!-- Note picker -->
<div class="note-picker-wrapper">
<button
class="btn-attach"
@click="toggleNotePicker"
:disabled="store.streaming || !store.chatReady"
title="Attach a note"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<div v-if="showNotePicker" class="note-picker-dropdown">
<input
class="panel-note-picker-search"
v-model="noteSearchQuery"
@input="onNoteSearchInput"
placeholder="Search notes..."
/>
<div class="note-picker-results">
<div
v-for="note in noteSearchResults"
:key="note.id"
class="note-picker-item"
@click="selectNote(note)"
>
{{ note.title || "Untitled" }}
</div>
<div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
<div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">
No notes found
</div>
</div>
</div>
</div>
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="store.chatReady ? 'Type a message...' : 'Chat unavailable'"
:disabled="store.streaming || !store.chatReady"
rows="1"
></textarea>
<button
class="btn-send"
@click="sendMessage"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
>
&uarr;
</button>
</div>
</div>
</aside>
</div>
@@ -135,7 +298,7 @@ function onInputKeydown(e: KeyboardEvent) {
.chat-panel-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
background: var(--color-overlay);
z-index: 100;
display: flex;
justify-content: flex-end;
@@ -168,7 +331,7 @@ function onInputKeydown(e: KeyboardEvent) {
color: var(--color-primary);
background: var(--color-bg-secondary);
padding: 0.15rem 0.5rem;
border-radius: 4px;
border-radius: var(--radius-sm);
}
.btn-close {
background: none;
@@ -187,6 +350,11 @@ function onInputKeydown(e: KeyboardEvent) {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
}
.messages-inner {
margin-top: auto;
}
/* Streaming bubble — matches ChatMessage assistant style */
@@ -238,16 +406,175 @@ function onInputKeydown(e: KeyboardEvent) {
50% { opacity: 1; }
}
/* Dark floating input */
/* Context pills */
.context-pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.35rem 0;
}
.context-pills-label {
font-size: 0.7rem;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.context-pill {
display: inline-flex;
align-items: center;
gap: 0.1rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.1rem 0.25rem 0.1rem 0.4rem;
font-size: 0.75rem;
}
.context-pill-link {
color: var(--color-primary);
text-decoration: none;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-pill-link:hover {
text-decoration: underline;
}
.context-pill-btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.8rem;
line-height: 1;
padding: 0 0.1rem;
color: var(--color-text-muted);
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.context-pill-btn:hover {
background: var(--color-border);
}
.context-pill-btn.promote:hover {
color: var(--color-primary);
}
.context-pill-btn.exclude:hover {
color: var(--color-danger, #e74c3c);
}
/* Input wrapper */
.panel-input-wrapper {
margin: 0 0.5rem 0.5rem;
}
/* Attached note */
.attached-note {
padding: 0.2rem 0.5rem;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.2rem;
background: var(--color-primary);
color: #fff;
border-radius: 10px;
padding: 0.15rem 0.4rem;
font-size: 0.75rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 0 0.1rem;
}
.attached-note-remove:hover {
color: #fff;
}
/* Floating input */
.panel-input {
display: flex;
align-items: flex-end;
align-items: center;
gap: 0.5rem;
margin: 0 0.5rem 0.5rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: #1c1c1e;
background: var(--color-input-bar-bg);
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
box-shadow: 0 2px 8px var(--color-shadow);
}
/* Note picker */
.note-picker-wrapper {
position: relative;
}
.btn-attach {
background: none;
border: none;
cursor: pointer;
color: var(--color-input-bar-text);
opacity: 0.6;
padding: 0.2rem;
display: flex;
align-items: center;
justify-content: center;
}
.btn-attach:hover {
opacity: 1;
}
.btn-attach:disabled {
opacity: 0.3;
cursor: default;
}
.note-picker-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 260px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 10;
overflow: hidden;
}
.panel-note-picker-search {
width: 100%;
padding: 0.45rem 0.65rem;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.note-picker-results {
max-height: 180px;
overflow-y: auto;
}
.note-picker-item {
padding: 0.4rem 0.65rem;
cursor: pointer;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-picker-item:hover {
background: var(--color-bg-secondary);
}
.note-picker-empty {
padding: 0.4rem 0.65rem;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.panel-input textarea {
flex: 1;
@@ -258,11 +585,13 @@ function onInputKeydown(e: KeyboardEvent) {
font-family: inherit;
font-size: 0.9rem;
background: transparent;
color: #fff;
color: var(--color-input-bar-text);
outline: none;
max-height: 150px;
overflow-y: auto;
}
.panel-input textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
color: var(--color-input-bar-placeholder);
}
.panel-input textarea:disabled {
opacity: 0.5;
+1 -1
View File
@@ -39,7 +39,7 @@ const buttons = [
.md-btn {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 3px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
+1 -1
View File
@@ -33,7 +33,7 @@ function onTagClick(tag: string) {
display: block;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
+1 -1
View File
@@ -75,7 +75,7 @@ function goToPage(page: number) {
.page-btn {
padding: 0.35rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
+1 -1
View File
@@ -25,7 +25,7 @@ watch(query, (val) => {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
font-size: 1rem;
box-sizing: border-box;
background: var(--color-bg-card);
+1 -1
View File
@@ -60,7 +60,7 @@ function isOverdue(): boolean {
display: block;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
+17 -2
View File
@@ -10,6 +10,7 @@ import {
import type {
Conversation,
ConversationDetail,
ContextMeta,
Message,
OllamaModel,
OllamaStatus,
@@ -22,6 +23,7 @@ export const useChatStore = defineStore("chat", () => {
const loading = ref(false);
const streaming = ref(false);
const streamingContent = ref("");
const lastContextMeta = ref<ContextMeta | null>(null);
const models = ref<OllamaModel[]>([]);
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "ready" | "not_found">("checking");
@@ -90,10 +92,15 @@ export const useChatStore = defineStore("chat", () => {
}
}
async function sendMessage(content: string, contextNoteId?: number | null) {
async function sendMessage(
content: string,
contextNoteId?: number | null,
excludeNoteIds?: number[],
) {
if (!currentConversation.value) return;
const convId = currentConversation.value.id;
lastContextMeta.value = null;
// Add optimistic user message
const userMsg: Message = {
@@ -112,8 +119,15 @@ export const useChatStore = defineStore("chat", () => {
try {
await apiStreamPost(
`/api/chat/conversations/${convId}/messages`,
{ content, context_note_id: contextNoteId },
{
content,
context_note_id: contextNoteId,
exclude_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
},
(data) => {
if (data.context) {
lastContextMeta.value = data.context as ContextMeta;
}
if (data.chunk) {
streamingContent.value += data.chunk as string;
}
@@ -207,6 +221,7 @@ export const useChatStore = defineStore("chat", () => {
loading,
streaming,
streamingContent,
lastContextMeta,
models,
ollamaStatus,
modelStatus,
+6
View File
@@ -25,6 +25,12 @@ export interface OllamaModel {
size: number;
}
export interface ContextMeta {
context_note_id: number | null;
context_note_title: string | null;
auto_notes: { id: number; title: string }[];
}
export interface OllamaStatus {
ollama: "available" | "unavailable";
model: "ready" | "not_found";
+381 -49
View File
@@ -3,8 +3,10 @@ import { onMounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { apiGet } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
import type { Note } from "@/types/note";
const route = useRoute();
const router = useRouter();
@@ -17,6 +19,17 @@ const inputEl = ref<HTMLTextAreaElement | null>(null);
const sending = ref(false);
const summarizing = ref(false);
// Note picker state
const attachedNote = ref<{ id: number; title: string } | null>(null);
const showNotePicker = ref(false);
const noteSearchQuery = ref("");
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Exclude tracking (session-scoped per conversation)
const excludedNoteIds = ref<Set<number>>(new Set());
const convId = computed(() => {
const id = route.params.id;
return id ? Number(id) : null;
@@ -41,6 +54,9 @@ onMounted(async () => {
});
watch(convId, async (newId) => {
excludedNoteIds.value = new Set();
attachedNote.value = null;
store.lastContextMeta = null;
if (newId) {
await store.fetchConversation(newId);
scrollToBottom();
@@ -92,10 +108,17 @@ async function sendMessage() {
}
sending.value = true;
const contextNoteId = attachedNote.value?.id ?? undefined;
attachedNote.value = null;
messageInput.value = "";
resetTextareaHeight();
scrollToBottom();
await store.sendMessage(content);
await store.sendMessage(
content,
contextNoteId,
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
);
sending.value = false;
// Refresh conversation list to show updated title/timestamps
@@ -130,12 +153,77 @@ async function handleSummarize() {
}
}
function autoResize() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 150) + "px";
}
function resetTextareaHeight() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
// Note picker
function toggleNotePicker() {
showNotePicker.value = !showNotePicker.value;
if (showNotePicker.value) {
noteSearchQuery.value = "";
noteSearchResults.value = [];
nextTick(() => {
const el = document.querySelector(".note-picker-search") as HTMLInputElement;
el?.focus();
});
}
}
function onNoteSearchInput() {
if (noteSearchTimer) clearTimeout(noteSearchTimer);
noteSearchTimer = setTimeout(async () => {
const q = noteSearchQuery.value.trim();
if (!q) {
noteSearchResults.value = [];
return;
}
noteSearchLoading.value = true;
try {
const data = await apiGet<{ notes: Note[] }>(
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
);
noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }));
} catch {
noteSearchResults.value = [];
} finally {
noteSearchLoading.value = false;
}
}, 250);
}
function selectNote(note: { id: number; title: string }) {
attachedNote.value = note;
showNotePicker.value = false;
}
function removeAttachedNote() {
attachedNote.value = null;
}
function promoteAutoNote(note: { id: number; title: string }) {
attachedNote.value = note;
}
function excludeAutoNote(noteId: number) {
excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]);
}
</script>
<template>
@@ -182,48 +270,126 @@ function onInputKeydown(e: KeyboardEvent) {
</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 class="messages-inner">
<ChatMessage
v-for="msg in store.currentConversation.messages"
:key="msg.id"
:message="msg"
@save-as-note="handleSaveAsNote"
/>
<!-- Streaming message (assistant typing) -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span class="typing-indicator"></span>
<!-- Context pills (auto-found notes) -->
<div
v-if="store.lastContextMeta?.auto_notes?.length && (store.streaming || store.currentConversation.messages.length)"
class="context-pills"
>
<span class="context-pills-label">Context:</span>
<span
v-for="note in store.lastContextMeta.auto_notes"
:key="note.id"
class="context-pill"
>
<router-link :to="`/notes/${note.id}`" class="context-pill-link">
{{ note.title }}
</router-link>
<button
class="context-pill-btn promote"
@click="promoteAutoNote(note)"
title="Attach this note for next message"
>+</button>
<button
class="context-pill-btn exclude"
@click="excludeAutoNote(note.id)"
title="Exclude from auto-search"
>&times;</button>
</span>
</div>
</div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
>
Send a message to start the conversation.
</p>
<!-- Streaming message (assistant typing) -->
<div v-if="store.streaming" class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div class="message-content prose" v-html="streamingRendered"></div>
<span class="typing-indicator"></span>
</div>
</div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
>
Send a message to start the conversation.
</p>
</div>
</div>
<div class="input-area">
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
:placeholder="inputPlaceholder"
:disabled="store.streaming || !store.chatReady"
rows="2"
></textarea>
<button
class="btn-send"
@click="sendMessage"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
>
&uarr;
</button>
<div class="input-wrapper">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" @click="removeAttachedNote">&times;</button>
</span>
</div>
<div class="input-area">
<!-- Note picker button -->
<div class="note-picker-wrapper">
<button
class="btn-attach"
@click="toggleNotePicker"
:disabled="store.streaming || !store.chatReady"
title="Attach a note"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<!-- Note picker dropdown -->
<div v-if="showNotePicker" class="note-picker-dropdown">
<input
class="note-picker-search"
v-model="noteSearchQuery"
@input="onNoteSearchInput"
placeholder="Search notes..."
/>
<div class="note-picker-results">
<div
v-for="note in noteSearchResults"
:key="note.id"
class="note-picker-item"
@click="selectNote(note)"
>
{{ note.title || "Untitled" }}
</div>
<div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
<div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">
No notes found
</div>
</div>
</div>
</div>
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="inputPlaceholder"
:disabled="store.streaming || !store.chatReady"
rows="1"
></textarea>
<button
class="btn-send"
@click="sendMessage"
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
>
&uarr;
</button>
</div>
</div>
</template>
@@ -259,7 +425,7 @@ function onInputKeydown(e: KeyboardEvent) {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
}
@@ -278,7 +444,7 @@ function onInputKeydown(e: KeyboardEvent) {
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
margin-bottom: 0.25rem;
}
@@ -339,7 +505,7 @@ function onInputKeydown(e: KeyboardEvent) {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
@@ -358,6 +524,11 @@ function onInputKeydown(e: KeyboardEvent) {
flex: 1;
overflow-y: auto;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
}
.messages-inner {
margin-top: auto;
}
/* Streaming bubble — matches ChatMessage assistant style */
@@ -409,16 +580,175 @@ function onInputKeydown(e: KeyboardEvent) {
50% { opacity: 1; }
}
/* Floating dark input bar */
/* Context pills */
.context-pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0;
}
.context-pills-label {
font-size: 0.75rem;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.context-pill {
display: inline-flex;
align-items: center;
gap: 0.15rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 0.15rem 0.35rem 0.15rem 0.5rem;
font-size: 0.8rem;
}
.context-pill-link {
color: var(--color-primary);
text-decoration: none;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-pill-link:hover {
text-decoration: underline;
}
.context-pill-btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
padding: 0 0.15rem;
color: var(--color-text-muted);
border-radius: 50%;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.context-pill-btn:hover {
background: var(--color-border);
}
.context-pill-btn.promote:hover {
color: var(--color-primary);
}
.context-pill-btn.exclude:hover {
color: var(--color-danger, #e74c3c);
}
/* Input wrapper */
.input-wrapper {
margin: 0 1rem 2rem;
}
/* Attached note */
.attached-note {
padding: 0.25rem 0.5rem;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 12px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
}
.attached-note-remove:hover {
color: #fff;
}
/* Floating input bar */
.input-area {
display: flex;
align-items: flex-end;
align-items: center;
gap: 0.5rem;
margin: 0 1rem 0.75rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: #1c1c1e;
background: var(--color-input-bar-bg);
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 12px var(--color-shadow);
}
/* Note picker */
.note-picker-wrapper {
position: relative;
}
.btn-attach {
background: none;
border: none;
cursor: pointer;
color: var(--color-input-bar-text);
opacity: 0.6;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.btn-attach:hover {
opacity: 1;
}
.btn-attach:disabled {
opacity: 0.3;
cursor: default;
}
.note-picker-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 280px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 10;
overflow: hidden;
}
.note-picker-search {
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.note-picker-results {
max-height: 200px;
overflow-y: auto;
}
.note-picker-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-picker-item:hover {
background: var(--color-bg-secondary);
}
.note-picker-empty {
padding: 0.5rem 0.75rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.input-area textarea {
flex: 1;
@@ -429,11 +759,13 @@ function onInputKeydown(e: KeyboardEvent) {
font-family: inherit;
font-size: 0.95rem;
background: transparent;
color: #fff;
color: var(--color-input-bar-text);
outline: none;
max-height: 150px;
overflow-y: auto;
}
.input-area textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
color: var(--color-input-bar-placeholder);
}
.input-area textarea:disabled {
opacity: 0.5;
@@ -483,7 +815,7 @@ function onInputKeydown(e: KeyboardEvent) {
.messages-container {
padding: 0.75rem;
}
.input-area {
.input-wrapper {
margin: 0 0.5rem 0.5rem;
}
}
+3 -3
View File
@@ -180,7 +180,7 @@ async function newChat() {
padding: 0.75rem 1rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 8px;
border-radius: var(--radius-md);
text-decoration: none;
color: var(--color-text);
transition: border-color 0.15s;
@@ -215,11 +215,11 @@ async function newChat() {
}
.btn-cta {
display: inline-block;
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
+13 -13
View File
@@ -287,11 +287,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-primary);
}
.btn-save {
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.btn-save:disabled {
@@ -299,18 +299,18 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
cursor: default;
}
.btn-delete {
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
margin-left: auto;
}
.title-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
font-size: 1.25rem;
font-weight: 600;
background: var(--color-bg-card);
@@ -322,7 +322,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
border: none;
background: none;
color: var(--color-text-secondary);
@@ -341,7 +341,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
font-size: 1rem;
font-family: monospace;
min-height: 200px;
@@ -354,7 +354,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
min-height: 200px;
background: var(--color-bg-card);
}
@@ -365,7 +365,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
@@ -384,7 +384,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
@@ -392,7 +392,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
}
.modal-card {
background: var(--color-bg-card);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 1.5rem;
max-width: 400px;
width: 90%;
@@ -413,9 +413,9 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
justify-content: flex-end;
}
.modal-btn {
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
+2 -2
View File
@@ -182,7 +182,7 @@ async function convertToTask() {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
@@ -242,6 +242,6 @@ async function convertToTask() {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 0.1rem 0.4rem;
border-radius: 3px;
border-radius: var(--radius-sm);
}
</style>
+6 -6
View File
@@ -147,10 +147,10 @@ function onOffsetUpdate(offset: number) {
margin: 0;
}
.btn-new {
padding: 0.5rem 1rem;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
}
@@ -167,7 +167,7 @@ function onOffsetUpdate(offset: number) {
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
@@ -175,7 +175,7 @@ function onOffsetUpdate(offset: number) {
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
@@ -223,10 +223,10 @@ function onOffsetUpdate(offset: number) {
}
.btn-cta {
display: inline-block;
padding: 0.5rem 1.25rem;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
}
+17 -17
View File
@@ -350,7 +350,7 @@ function cancelDelete() {
<style scoped>
.settings-page {
max-width: 700px;
max-width: 720px;
margin: 2rem auto;
padding: 0 1rem;
}
@@ -360,7 +360,7 @@ function cancelDelete() {
.settings-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
@@ -387,7 +387,7 @@ function cancelDelete() {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
border-radius: var(--radius-sm);
font-size: 0.95rem;
background: var(--color-bg);
color: var(--color-text);
@@ -408,11 +408,11 @@ function cancelDelete() {
gap: 0.75rem;
}
.btn-save {
padding: 0.5rem 1.25rem;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
}
@@ -424,7 +424,7 @@ function cancelDelete() {
opacity: 0.9;
}
.saved-msg {
color: #22c55e;
color: var(--color-success);
font-size: 0.9rem;
font-weight: 600;
}
@@ -442,7 +442,7 @@ function cancelDelete() {
gap: 1rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
border-radius: var(--radius-md);
background: var(--color-bg);
transition: border-color 0.15s;
}
@@ -489,7 +489,7 @@ function cancelDelete() {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
@@ -505,7 +505,7 @@ function cancelDelete() {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
@@ -521,7 +521,7 @@ function cancelDelete() {
padding: 0.25rem 0.75rem;
background: var(--color-primary);
color: #fff;
border-radius: 12px;
border-radius: var(--radius-lg);
font-size: 0.8rem;
font-weight: 600;
}
@@ -556,13 +556,13 @@ function cancelDelete() {
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.75rem;
}
.btn-remove:hover:not(:disabled) {
border-color: #ef4444;
color: #ef4444;
border-color: var(--color-danger);
color: var(--color-danger);
}
.btn-remove:disabled {
opacity: 0.4;
@@ -570,16 +570,16 @@ function cancelDelete() {
}
.btn-confirm-delete {
padding: 0.25rem 0.6rem;
background: #ef4444;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
}
.btn-confirm-delete:hover:not(:disabled) {
background: #dc2626;
filter: brightness(0.9);
}
.btn-confirm-delete:disabled {
opacity: 0.6;
@@ -590,7 +590,7 @@ function cancelDelete() {
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.75rem;
}
+14 -14
View File
@@ -336,11 +336,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-primary);
}
.btn-save {
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.btn-save:disabled {
@@ -348,18 +348,18 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
cursor: default;
}
.btn-delete {
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
background: var(--color-danger);
color: #fff;
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
margin-left: auto;
}
.title-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
font-size: 1.25rem;
font-weight: 600;
background: var(--color-bg-card);
@@ -371,7 +371,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
border: none;
background: none;
color: var(--color-text-secondary);
@@ -390,7 +390,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
font-size: 1rem;
font-family: monospace;
min-height: 200px;
@@ -403,7 +403,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
min-height: 200px;
background: var(--color-bg-card);
}
@@ -414,7 +414,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
margin: 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
box-shadow: 0 4px 12px var(--color-shadow);
max-height: 200px;
@@ -451,7 +451,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
.field-input {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
@@ -459,7 +459,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
@@ -467,7 +467,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
}
.modal-card {
background: var(--color-bg-card);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 1.5rem;
max-width: 400px;
width: 90%;
@@ -488,9 +488,9 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
justify-content: flex-end;
}
.modal-btn {
padding: 0.4rem 1rem;
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
+2 -2
View File
@@ -212,7 +212,7 @@ function onTagClick(tag: string) {
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
@@ -286,6 +286,6 @@ function onTagClick(tag: string) {
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 0.1rem 0.4rem;
border-radius: 3px;
border-radius: var(--radius-sm);
}
</style>
+6 -6
View File
@@ -184,10 +184,10 @@ function onOffsetUpdate(offset: number) {
margin: 0;
}
.btn-new {
padding: 0.5rem 1rem;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
}
@@ -206,7 +206,7 @@ function onOffsetUpdate(offset: number) {
.sort-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
@@ -219,7 +219,7 @@ function onOffsetUpdate(offset: number) {
.sort-order {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
@@ -267,10 +267,10 @@ function onOffsetUpdate(offset: number) {
}
.btn-cta {
display: inline-block;
padding: 0.5rem 1.25rem;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border-radius: 4px;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
}
+8 -1
View File
@@ -85,6 +85,7 @@ async def send_message_route(conv_id: int):
if not content:
return jsonify({"error": "content is required"}), 400
context_note_id = data.get("context_note_id")
exclude_note_ids = data.get("exclude_note_ids") or []
# Save user message
await add_message(conv_id, "user", content, context_note_id=context_note_id)
@@ -96,11 +97,17 @@ async def send_message_route(conv_id: int):
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)
messages, context_meta = await build_context(
history, context_note_id, content, exclude_note_ids=exclude_note_ids
)
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL)
async def generate():
# Emit context metadata before streaming LLM response
context_event = json.dumps({"context": context_meta})
yield f"data: {context_event}\n\n"
full_response = []
try:
async for chunk in stream_chat(messages, model):
+26
View File
@@ -1,10 +1,29 @@
import httpx
from quart import Blueprint, jsonify, request
from fabledassistant.config import Config
from fabledassistant.services.settings import get_all_settings, set_setting
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
async def _get_installed_models() -> set[str]:
"""Return set of installed Ollama model names, with and without :latest."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
data = resp.json()
names: set[str] = set()
for m in data.get("models", []):
name = m["name"]
names.add(name)
names.add(name.replace(":latest", ""))
return names
except Exception:
return set()
@settings_bp.route("", methods=["GET"])
async def get_settings_route():
settings = await get_all_settings()
@@ -16,6 +35,13 @@ async def update_settings_route():
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if "default_model" in data:
model = str(data["default_model"])
installed = await _get_installed_models()
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
for key, value in data.items():
await set_setting(key, str(value))
settings = await get_all_settings()
+7 -4
View File
@@ -14,10 +14,13 @@ async def create_conversation(title: str = "", model: str = "") -> Conversation:
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
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
result = await session.execute(
select(Conversation)
.options(selectinload(Conversation.messages))
.where(Conversation.id == conv.id)
)
return result.scalars().first()
async def get_conversation(conversation_id: int) -> Conversation | None:
+44 -19
View File
@@ -138,8 +138,14 @@ 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."""
exclude_note_ids: list[int] | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
Returns (messages, context_meta) where context_meta contains info about
which notes were included as context.
"""
exclude_set = set(exclude_note_ids or [])
assistant_name = await get_setting("assistant_name", "Fable")
system_parts = [
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
@@ -147,10 +153,18 @@ async def build_context(
"When note context is provided, use it to give relevant answers."
]
context_meta: dict = {
"context_note_id": None,
"context_note_title": None,
"auto_notes": [],
}
# Include current note context if provided
if current_note_id:
note = await get_note(current_note_id)
if note:
context_meta["context_note_id"] = note.id
context_meta["context_note_title"] = note.title
system_parts.append(
f"\n\n--- Current Note ---\n"
f"Title: {note.title}\n"
@@ -158,28 +172,39 @@ async def build_context(
f"--- End Note ---"
)
# Search notes by keywords from user message
# Search notes by keywords from user message — search per keyword
# individually and deduplicate to get OR-style matching (any keyword hit
# is relevant context). Collect up to 3 unique notes.
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 = []
seen_ids: set[int] = set()
snippets: list[str] = []
for kw in keywords[:5]:
if len(snippets) >= 3:
break
try:
notes, _ = await list_notes(q=kw, limit=3)
for n in notes:
# Skip the current note (already included)
if n.id in seen_ids:
continue
if current_note_id and n.id == current_note_id:
continue
body_preview = n.body[:300] if n.body else ""
if n.id in exclude_set:
continue
seen_ids.add(n.id)
body_preview = n.body[:2000] 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
context_meta["auto_notes"].append({"id": n.id, "title": n.title})
if len(snippets) >= 3:
break
except Exception:
continue
if snippets:
system_parts.append(
"\n\n--- Related Notes ---\n"
+ "\n".join(snippets)
+ "\n--- End Related Notes ---"
)
# Fetch URL content from user message
urls = _find_urls(user_message)
@@ -193,4 +218,4 @@ async def build_context(
messages = [{"role": "system", "content": "".join(system_parts)}]
messages.extend(history)
messages.append({"role": "user", "content": user_message})
return messages
return messages, context_meta
+7 -5
View File
@@ -60,11 +60,13 @@ async def list_notes(
count_query = count_query.where(Note.status.is_(None))
if q:
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped_q}%"
filter_ = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
query = query.where(filter_)
count_query = count_query.where(filter_)
terms = q.split()
for term in terms:
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped_term}%"
term_filter = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
query = query.where(term_filter)
count_query = count_query.where(term_filter)
if tags:
for i, tag in enumerate(tags):
+47 -13
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-02-10 — Phase 4.5: Chat UX improvements, settings page, model management, entity rendering fix
2026-02-10 — Phase 4.7: Styling consistency pass — design tokens, header alignment, standardized UI
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -50,6 +50,10 @@ for AI-assisted features.
`LIKE` prefix.
- **Dark-first theming:** CSS custom properties on `:root` (light) and
`[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark.
- **Design tokens:** Standardized CSS custom properties for border-radius
(`--radius-sm: 6px`, `--radius-md: 8px`, `--radius-lg: 12px`), semantic colors
(`--color-success`, `--color-warning`, `--color-overlay`), and focus ring
(`--focus-ring`). All components reference tokens instead of hardcoded values.
- **Unified note/task model:** A task is just a note with task attributes enabled.
A note has `status IS NOT NULL` ⟹ it's a task. "Convert to task" sets
`status='todo'`; "convert to note" clears `status`, `priority`, `due_date`.
@@ -62,8 +66,11 @@ for AI-assisted features.
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).
frontend just sends the message text + optional note ID + optional exclude list.
Keyword extraction uses simple word splitting with stopword filtering (no embeddings).
`build_context()` returns `(messages, context_meta)` tuple; metadata includes
auto-found note IDs/titles sent to frontend via SSE `context` event before streaming.
Multi-word search splits terms into per-word ILIKE with AND logic (not adjacent match).
- **No blocking long-running operations:** Any potentially slow operation (model
pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the
UI. Backend uses SSE streaming for incremental responses (chat messages and model
@@ -185,7 +192,7 @@ fabledassistant/
│ ├── services/
│ │ ├── 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.)
│ │ ├── llm.py # Ollama interaction: ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, note search, URL fetching, configurable assistant name)
│ │ ├── llm.py # Ollama interaction: ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, note search with 5 keywords + 2000-char previews, exclude_note_ids support, returns (messages, context_meta) tuple, URL fetching, configurable assistant name)
│ │ ├── chat.py # Conversation CRUD, add_message, save_response_as_note, summarize_conversation_as_note
│ │ └── settings.py # Settings CRUD: get_setting, set_setting, get_all_settings
│ ├── utils/
@@ -200,7 +207,7 @@ fabledassistant/
│ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification; starts status polling + loads settings on mount
│ ├── main.ts # App init, imports theme.css
│ ├── assets/
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens (radius, semantic colors, focus ring, overlay), global focus-visible styles
│ ├── api/
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete + apiStreamPost (SSE via fetch + ReadableStream)
│ ├── composables/
@@ -209,19 +216,19 @@ fabledassistant/
│ ├── stores/
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming, handles context event, accepts excludeNoteIds), lastContextMeta ref, saveMessageAsNote, summarizeAsNote, fetchModels, status polling (ollamaStatus, modelStatus, chatReady)
│ │ ├── settings.ts # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel (SSE streaming with onProgress callback), deleteModel
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
│ ├── types/
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, OllamaModel, OllamaStatus interfaces
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, OllamaStatus interfaces
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── utils/
│ │ ├── tags.ts # extractTags(), linkifyTags() (with (?<!&) to skip HTML entities), linkifyWikilinks()
│ │ └── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces &#39; after sanitization
│ ├── views/
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar + bubble-style messages (user right, assistant left) + floating dark input bar + auto-focus
│ │ ├── ChatView.vue # Dedicated /chat page: sidebar + bubble-style messages (user right, assistant left) + floating dark input bar + auto-focus + note picker + context pills (auto-found notes with promote/exclude) + exclude tracking
│ │ ├── HomeView.vue # Landing page: recent notes + tasks + recent chats (3 most recent) + new chat button
│ │ ├── SettingsView.vue # Settings page: assistant name config + model catalog (18 models in 3 categories) with download/select/remove
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
@@ -232,7 +239,7 @@ fabledassistant/
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
│ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks + Chat + Settings links, status indicator dot, chat panel toggle, theme toggle
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote/exclude
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, configurable assistant name label, "Save as Note" action on assistant messages
│ │ ├── 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
@@ -275,7 +282,7 @@ fabledassistant/
| 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/conversations/:id/messages` | Send message + stream LLM response via SSE (body: `{content, context_note_id?, exclude_note_ids?}`); emits `{context: ContextMeta}` SSE event before streaming chunks |
| 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/status` | Check Ollama availability and model readiness (`{ollama, model, default_model}`) |
@@ -426,7 +433,7 @@ When adding a new migration, follow these conventions:
### Phase 4 — LLM Chat Integration ✓
- [x] **Ollama client:** async HTTP via httpx — `ensure_model()` (auto-pull on startup), `stream_chat()` (streaming NDJSON), `generate_completion()` (non-streaming)
- [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)
- [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3, 5 keywords, 2000-char previews), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars), returns `(messages, context_meta)` tuple with auto-found note IDs/titles, accepts `exclude_note_ids` to skip notes during auto-search
- [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD
- [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish
- [x] **Save as note:** Save any assistant message as a new note (first line as title)
@@ -451,6 +458,29 @@ When adding a new migration, follow these conventions:
- [x] **New chat navigation fix:** `fetchConversation()` called before `router.push()` so `currentConversation` is set immediately
- [x] **Recent chats on home page:** 3 most recent conversations shown as clickable cards + "New Chat" button
### Phase 4.6 — Improved Note Context in Chat ✓
- [x] **Multi-word search:** `list_notes()` splits multi-word queries into per-term ILIKE filters with AND logic (each word matched independently, not adjacent)
- [x] **Context metadata:** `build_context()` returns `(messages, context_meta)` tuple with auto-found note IDs/titles and attached note info
- [x] **Fuller auto-context:** Auto-found note previews increased from 300 to 2000 chars; uses all 5 extracted keywords instead of 3
- [x] **Context SSE event:** Backend emits `{context: ContextMeta}` SSE event before streaming LLM response chunks
- [x] **Exclude note IDs:** Backend accepts `exclude_note_ids` in request body, skips those notes during auto-search
- [x] **Context pills:** ChatView and ChatPanel show auto-found notes as pills above streaming response with note title as router-link
- [x] **Promote/exclude controls:** "+" button on context pills promotes note to attached context for next message; "x" button excludes note from future auto-searches in the session
- [x] **Note picker:** Paperclip button in chat input opens dropdown with debounced search, selecting a note attaches it as context (shown as pill above input)
- [x] **ContextMeta type:** New TypeScript interface for context metadata (`context_note_id`, `context_note_title`, `auto_notes`)
- [x] **Session-scoped excludes:** Excluded note IDs tracked per conversation session, cleared on conversation switch
### Phase 4.7 — Styling Consistency Pass ✓
- [x] **Design tokens:** Added `--radius-sm/md/lg/pill`, `--color-success/warning/overlay`, `--focus-ring` to theme.css
- [x] **Header alignment fix:** Removed `max-width: 960px` from header nav — now full-width with consistent padding, matching modern app layout
- [x] **Active nav link state:** Added `router-link-active` styling with primary color + card background
- [x] **Hardcoded colors replaced:** Status indicator dots, settings saved message, remove/confirm-delete buttons now use CSS variables
- [x] **Border-radius standardized:** All components migrated from mixed 3px/4px/6px/8px to `var(--radius-sm)` (6px) for controls, `var(--radius-md)` (8px) for cards/containers
- [x] **Button padding standardized:** All primary/CTA buttons use `0.45rem 1rem`; small buttons use `0.3rem 0.75rem`
- [x] **Settings max-width fixed:** Changed from 700px to 720px to match all other views
- [x] **Focus-visible states:** Global `focus-visible` rule with `--focus-ring` shadow on inputs, textareas, selects, buttons
- [x] **Modal overlays:** Replaced hardcoded `rgba(0,0,0,0.5)` with `var(--color-overlay)` across all components
### Phase 5 — Polish & Production Hardening
- [ ] Authentication (single-user or multi-user, TBD)
- [ ] Docker Swarm production stack (secrets, volumes, networking)
@@ -476,11 +506,15 @@ When adding a new migration, follow these conventions:
- Authentication model: single-user (password-only) vs multi-user?
## Current Status
**Phase:** Phase 4.5 complete. Chat UX improvements, settings page, model management.
**Phase:** Phase 4.7 complete. Styling consistency pass with design tokens and standardized UI.
- Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags
- Unified note/task model (a task is a note with `status IS NOT NULL`)
- LLM chat via Ollama with SSE streaming responses
- Note-aware context: auto-includes current note + searches related notes by keyword
- Note-aware context: auto-includes current note + searches related notes by keyword (5 keywords, 2000-char previews)
- Context visibility: auto-found notes shown as pills with title links, promote (+) and exclude (x) controls
- Note picker: attach any note as context from chat input via search dropdown
- Exclude tracking: session-scoped per conversation, excluded notes skipped in future auto-searches
- Multi-word search: splits query into per-term ILIKE with AND logic (words match independently)
- URL content fetching: detects URLs in messages, fetches and includes content
- Save assistant messages as notes; summarize entire conversations as notes
- Dedicated `/chat` page with bubble-style messages (user right, assistant left), floating dark input bar