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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>×</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"
|
||||
>
|
||||
↑
|
||||
</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">×</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"
|
||||
>
|
||||
↑
|
||||
</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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
>×</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"
|
||||
>
|
||||
↑
|
||||
</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">×</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"
|
||||
>
|
||||
↑
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ' 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
|
||||
|
||||
Reference in New Issue
Block a user