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-bg: #f6f8fa;
|
||||||
--color-code-inline-bg: #eff1f3;
|
--color-code-inline-bg: #eff1f3;
|
||||||
--color-table-stripe: #f9f9f9;
|
--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"] {
|
[data-theme="dark"] {
|
||||||
@@ -68,6 +79,12 @@
|
|||||||
--color-code-bg: #161b22;
|
--color-code-bg: #161b22;
|
||||||
--color-code-inline-bg: #2a3040;
|
--color-code-inline-bg: #2a3040;
|
||||||
--color-table-stripe: #1a2030;
|
--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;
|
line-height: 1.5;
|
||||||
transition: background-color 0.2s, color 0.2s;
|
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);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.nav {
|
.nav {
|
||||||
max-width: 960px;
|
padding: 0.5rem 1.25rem;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -70,19 +68,29 @@ const statusClass = computed(() => {
|
|||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
.nav-link {
|
.nav-link {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-decoration: none;
|
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 {
|
.nav-link:hover {
|
||||||
color: var(--color-primary);
|
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 {
|
.status-indicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -91,14 +99,14 @@ const statusClass = computed(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.status-green .status-dot {
|
.status-green .status-dot {
|
||||||
background: #22c55e;
|
background: var(--color-success);
|
||||||
}
|
}
|
||||||
.status-yellow .status-dot {
|
.status-yellow .status-dot {
|
||||||
background: #eab308;
|
background: var(--color-warning);
|
||||||
animation: pulse-dot 2s infinite;
|
animation: pulse-dot 2s infinite;
|
||||||
}
|
}
|
||||||
.status-red .status-dot {
|
.status-red .status-dot {
|
||||||
background: #ef4444;
|
background: var(--color-danger);
|
||||||
}
|
}
|
||||||
@keyframes pulse-dot {
|
@keyframes pulse-dot {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -107,12 +115,13 @@ const statusClass = computed(() => {
|
|||||||
.btn-chat-panel {
|
.btn-chat-panel {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
.btn-chat-panel:hover {
|
.btn-chat-panel:hover {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
@@ -120,7 +129,7 @@ const statusClass = computed(() => {
|
|||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const rendered = computed(() => renderMarkdown(props.message.content));
|
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(() => {
|
const roleLabel = computed(() => {
|
||||||
switch (props.message.role) {
|
switch (props.message.role) {
|
||||||
case "user":
|
case "user":
|
||||||
@@ -31,24 +37,27 @@ const roleLabel = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-message" :class="`role-${message.role}`">
|
<div class="chat-message" :class="`role-${message.role}`">
|
||||||
<div class="message-bubble">
|
<div class="message-group">
|
||||||
<div class="message-header">
|
<div class="message-bubble">
|
||||||
<span class="role-label">{{ roleLabel }}</span>
|
<div class="message-header">
|
||||||
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
<span class="role-label">{{ roleLabel }}</span>
|
||||||
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
|
||||||
Save as Note
|
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
|
||||||
</button>
|
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>
|
</div>
|
||||||
<div class="message-content prose" v-html="rendered"></div>
|
<span v-if="formattedTime" class="message-timestamp">{{ formattedTime }}</span>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -65,8 +74,11 @@ const roleLabel = computed(() => {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-group {
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
@@ -136,7 +148,7 @@ const roleLabel = computed(() => {
|
|||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -159,4 +171,12 @@ const roleLabel = computed(() => {
|
|||||||
.context-badge a:hover {
|
.context-badge a:hover {
|
||||||
text-decoration: underline;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import { ref, computed, watch, nextTick } from "vue";
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { apiGet } from "@/api/client";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import ChatMessage from "@/components/ChatMessage.vue";
|
import ChatMessage from "@/components/ChatMessage.vue";
|
||||||
|
import type { Note } from "@/types/note";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
contextNoteId?: number | null;
|
contextNoteId?: number | null;
|
||||||
@@ -17,6 +19,18 @@ const store = useChatStore();
|
|||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const messageInput = ref("");
|
const messageInput = ref("");
|
||||||
const messagesEl = ref<HTMLElement | null>(null);
|
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(() => {
|
const streamingRendered = computed(() => {
|
||||||
if (!store.streamingContent) return "";
|
if (!store.streamingContent) return "";
|
||||||
@@ -46,9 +60,16 @@ async function sendMessage() {
|
|||||||
await store.fetchConversation(conv.id);
|
await store.fetchConversation(conv.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contextNoteId = attachedNote.value?.id ?? props.contextNoteId;
|
||||||
|
attachedNote.value = null;
|
||||||
messageInput.value = "";
|
messageInput.value = "";
|
||||||
|
resetTextareaHeight();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
await store.sendMessage(content, props.contextNoteId);
|
await store.sendMessage(
|
||||||
|
content,
|
||||||
|
contextNoteId,
|
||||||
|
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
|
||||||
|
);
|
||||||
scrollToBottom();
|
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) {
|
function onInputKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -83,49 +169,126 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="messagesEl" class="panel-messages">
|
<div ref="messagesEl" class="panel-messages">
|
||||||
<template v-if="store.currentConversation">
|
<div class="messages-inner">
|
||||||
<ChatMessage
|
<template v-if="store.currentConversation">
|
||||||
v-for="msg in store.currentConversation.messages"
|
<ChatMessage
|
||||||
:key="msg.id"
|
v-for="msg in store.currentConversation.messages"
|
||||||
:message="msg"
|
:key="msg.id"
|
||||||
@save-as-note="handleSaveAsNote"
|
:message="msg"
|
||||||
/>
|
@save-as-note="handleSaveAsNote"
|
||||||
</template>
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Streaming bubble -->
|
<!-- Context pills -->
|
||||||
<div v-if="store.streaming" class="chat-message role-assistant">
|
<div
|
||||||
<div class="streaming-bubble">
|
v-if="store.lastContextMeta?.auto_notes?.length && (store.streaming || store.currentConversation?.messages.length)"
|
||||||
<div class="message-header">
|
class="context-pills"
|
||||||
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
>
|
||||||
</div>
|
<span class="context-pills-label">Context:</span>
|
||||||
<div class="message-content prose" v-html="streamingRendered"></div>
|
<span
|
||||||
<span class="typing-indicator"></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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
<!-- Streaming bubble -->
|
||||||
v-if="!store.currentConversation?.messages.length && !store.streaming"
|
<div v-if="store.streaming" class="chat-message role-assistant">
|
||||||
class="empty-msg"
|
<div class="streaming-bubble">
|
||||||
>
|
<div class="message-header">
|
||||||
Start chatting...
|
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div class="panel-input">
|
<div class="panel-input-wrapper">
|
||||||
<textarea
|
<!-- Attached note pill -->
|
||||||
v-model="messageInput"
|
<div v-if="attachedNote" class="attached-note">
|
||||||
@keydown="onInputKeydown"
|
<span class="attached-note-pill">
|
||||||
:placeholder="store.chatReady ? 'Type a message...' : 'Chat unavailable'"
|
{{ attachedNote.title }}
|
||||||
:disabled="store.streaming || !store.chatReady"
|
<button class="attached-note-remove" @click="removeAttachedNote">×</button>
|
||||||
rows="2"
|
</span>
|
||||||
></textarea>
|
</div>
|
||||||
<button
|
|
||||||
class="btn-send"
|
<div class="panel-input">
|
||||||
@click="sendMessage"
|
<!-- Note picker -->
|
||||||
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
|
<div class="note-picker-wrapper">
|
||||||
>
|
<button
|
||||||
↑
|
class="btn-attach"
|
||||||
</button>
|
@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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +298,7 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
.chat-panel-overlay {
|
.chat-panel-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--color-overlay);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -168,7 +331,7 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.btn-close {
|
.btn-close {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -187,6 +350,11 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.messages-inner {
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Streaming bubble — matches ChatMessage assistant style */
|
/* Streaming bubble — matches ChatMessage assistant style */
|
||||||
@@ -238,16 +406,175 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
50% { opacity: 1; }
|
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 {
|
.panel-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 0 0.5rem 0.5rem;
|
|
||||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||||
background: #1c1c1e;
|
background: var(--color-input-bar-bg);
|
||||||
border-radius: 16px;
|
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 {
|
.panel-input textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -258,11 +585,13 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #fff;
|
color: var(--color-input-bar-text);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.panel-input textarea::placeholder {
|
.panel-input textarea::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--color-input-bar-placeholder);
|
||||||
}
|
}
|
||||||
.panel-input textarea:disabled {
|
.panel-input textarea:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const buttons = [
|
|||||||
.md-btn {
|
.md-btn {
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function onTagClick(tag: string) {
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function goToPage(page: number) {
|
|||||||
.page-btn {
|
.page-btn {
|
||||||
padding: 0.35rem 0.7rem;
|
padding: 0.35rem 0.7rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ watch(query, (val) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function isOverdue(): boolean {
|
|||||||
display: block;
|
display: block;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationDetail,
|
ConversationDetail,
|
||||||
|
ContextMeta,
|
||||||
Message,
|
Message,
|
||||||
OllamaModel,
|
OllamaModel,
|
||||||
OllamaStatus,
|
OllamaStatus,
|
||||||
@@ -22,6 +23,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const streaming = ref(false);
|
const streaming = ref(false);
|
||||||
const streamingContent = ref("");
|
const streamingContent = ref("");
|
||||||
|
const lastContextMeta = ref<ContextMeta | null>(null);
|
||||||
const models = ref<OllamaModel[]>([]);
|
const models = ref<OllamaModel[]>([]);
|
||||||
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
|
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
|
||||||
const modelStatus = ref<"checking" | "ready" | "not_found">("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;
|
if (!currentConversation.value) return;
|
||||||
|
|
||||||
const convId = currentConversation.value.id;
|
const convId = currentConversation.value.id;
|
||||||
|
lastContextMeta.value = null;
|
||||||
|
|
||||||
// Add optimistic user message
|
// Add optimistic user message
|
||||||
const userMsg: Message = {
|
const userMsg: Message = {
|
||||||
@@ -112,8 +119,15 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
try {
|
try {
|
||||||
await apiStreamPost(
|
await apiStreamPost(
|
||||||
`/api/chat/conversations/${convId}/messages`,
|
`/api/chat/conversations/${convId}/messages`,
|
||||||
{ content, context_note_id: contextNoteId },
|
{
|
||||||
|
content,
|
||||||
|
context_note_id: contextNoteId,
|
||||||
|
exclude_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
|
||||||
|
},
|
||||||
(data) => {
|
(data) => {
|
||||||
|
if (data.context) {
|
||||||
|
lastContextMeta.value = data.context as ContextMeta;
|
||||||
|
}
|
||||||
if (data.chunk) {
|
if (data.chunk) {
|
||||||
streamingContent.value += data.chunk as string;
|
streamingContent.value += data.chunk as string;
|
||||||
}
|
}
|
||||||
@@ -207,6 +221,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
loading,
|
loading,
|
||||||
streaming,
|
streaming,
|
||||||
streamingContent,
|
streamingContent,
|
||||||
|
lastContextMeta,
|
||||||
models,
|
models,
|
||||||
ollamaStatus,
|
ollamaStatus,
|
||||||
modelStatus,
|
modelStatus,
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export interface OllamaModel {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContextMeta {
|
||||||
|
context_note_id: number | null;
|
||||||
|
context_note_title: string | null;
|
||||||
|
auto_notes: { id: number; title: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface OllamaStatus {
|
export interface OllamaStatus {
|
||||||
ollama: "available" | "unavailable";
|
ollama: "available" | "unavailable";
|
||||||
model: "ready" | "not_found";
|
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 { useRoute, useRouter } from "vue-router";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { apiGet } from "@/api/client";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import ChatMessage from "@/components/ChatMessage.vue";
|
import ChatMessage from "@/components/ChatMessage.vue";
|
||||||
|
import type { Note } from "@/types/note";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,6 +19,17 @@ const inputEl = ref<HTMLTextAreaElement | null>(null);
|
|||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const summarizing = 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 convId = computed(() => {
|
||||||
const id = route.params.id;
|
const id = route.params.id;
|
||||||
return id ? Number(id) : null;
|
return id ? Number(id) : null;
|
||||||
@@ -41,6 +54,9 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(convId, async (newId) => {
|
watch(convId, async (newId) => {
|
||||||
|
excludedNoteIds.value = new Set();
|
||||||
|
attachedNote.value = null;
|
||||||
|
store.lastContextMeta = null;
|
||||||
if (newId) {
|
if (newId) {
|
||||||
await store.fetchConversation(newId);
|
await store.fetchConversation(newId);
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -92,10 +108,17 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
|
const contextNoteId = attachedNote.value?.id ?? undefined;
|
||||||
|
attachedNote.value = null;
|
||||||
messageInput.value = "";
|
messageInput.value = "";
|
||||||
|
resetTextareaHeight();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|
||||||
await store.sendMessage(content);
|
await store.sendMessage(
|
||||||
|
content,
|
||||||
|
contextNoteId,
|
||||||
|
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
|
||||||
|
);
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
|
|
||||||
// Refresh conversation list to show updated title/timestamps
|
// 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) {
|
function onInputKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendMessage();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -182,48 +270,126 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="messagesEl" class="messages-container">
|
<div ref="messagesEl" class="messages-container">
|
||||||
<ChatMessage
|
<div class="messages-inner">
|
||||||
v-for="msg in store.currentConversation.messages"
|
<ChatMessage
|
||||||
:key="msg.id"
|
v-for="msg in store.currentConversation.messages"
|
||||||
:message="msg"
|
:key="msg.id"
|
||||||
@save-as-note="handleSaveAsNote"
|
:message="msg"
|
||||||
/>
|
@save-as-note="handleSaveAsNote"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Streaming message (assistant typing) -->
|
<!-- Context pills (auto-found notes) -->
|
||||||
<div v-if="store.streaming" class="chat-message role-assistant">
|
<div
|
||||||
<div class="message-bubble streaming-bubble">
|
v-if="store.lastContextMeta?.auto_notes?.length && (store.streaming || store.currentConversation.messages.length)"
|
||||||
<div class="message-header">
|
class="context-pills"
|
||||||
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
>
|
||||||
</div>
|
<span class="context-pills-label">Context:</span>
|
||||||
<div class="message-content prose" v-html="streamingRendered"></div>
|
<span
|
||||||
<span class="typing-indicator"></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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
<!-- Streaming message (assistant typing) -->
|
||||||
v-if="!store.currentConversation.messages.length && !store.streaming"
|
<div v-if="store.streaming" class="chat-message role-assistant">
|
||||||
class="empty-msg"
|
<div class="message-bubble streaming-bubble">
|
||||||
>
|
<div class="message-header">
|
||||||
Send a message to start the conversation.
|
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div class="input-area">
|
<div class="input-wrapper">
|
||||||
<textarea
|
<!-- Attached note pill -->
|
||||||
ref="inputEl"
|
<div v-if="attachedNote" class="attached-note">
|
||||||
v-model="messageInput"
|
<span class="attached-note-pill">
|
||||||
@keydown="onInputKeydown"
|
{{ attachedNote.title }}
|
||||||
:placeholder="inputPlaceholder"
|
<button class="attached-note-remove" @click="removeAttachedNote">×</button>
|
||||||
:disabled="store.streaming || !store.chatReady"
|
</span>
|
||||||
rows="2"
|
</div>
|
||||||
></textarea>
|
|
||||||
<button
|
<div class="input-area">
|
||||||
class="btn-send"
|
<!-- Note picker button -->
|
||||||
@click="sendMessage"
|
<div class="note-picker-wrapper">
|
||||||
:disabled="!messageInput.trim() || store.streaming || !store.chatReady"
|
<button
|
||||||
>
|
class="btn-attach"
|
||||||
↑
|
@click="toggleNotePicker"
|
||||||
</button>
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -259,7 +425,7 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -278,7 +444,7 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -339,7 +505,7 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -358,6 +524,11 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.messages-inner {
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Streaming bubble — matches ChatMessage assistant style */
|
/* Streaming bubble — matches ChatMessage assistant style */
|
||||||
@@ -409,16 +580,175 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
50% { opacity: 1; }
|
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 {
|
.input-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin: 0 1rem 0.75rem;
|
|
||||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||||
background: #1c1c1e;
|
background: var(--color-input-bar-bg);
|
||||||
border-radius: 20px;
|
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 {
|
.input-area textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -429,11 +759,13 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #fff;
|
color: var(--color-input-bar-text);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.input-area textarea::placeholder {
|
.input-area textarea::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--color-input-bar-placeholder);
|
||||||
}
|
}
|
||||||
.input-area textarea:disabled {
|
.input-area textarea:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@@ -483,7 +815,7 @@ function onInputKeydown(e: KeyboardEvent) {
|
|||||||
.messages-container {
|
.messages-container {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
.input-area {
|
.input-wrapper {
|
||||||
margin: 0 0.5rem 0.5rem;
|
margin: 0 0.5rem 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ async function newChat() {
|
|||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
@@ -215,11 +215,11 @@ async function newChat() {
|
|||||||
}
|
}
|
||||||
.btn-cta {
|
.btn-cta {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -287,11 +287,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.btn-save {
|
.btn-save {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-save:disabled {
|
.btn-save:disabled {
|
||||||
@@ -299,18 +299,18 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-danger);
|
background: var(--color-danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.title-input {
|
.title-input {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
@@ -322,7 +322,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
@@ -341,7 +341,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@@ -354,7 +354,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
.preview-pane {
|
.preview-pane {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
}
|
}
|
||||||
@@ -365,7 +365,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
box-shadow: 0 4px 12px var(--color-shadow);
|
box-shadow: 0 4px 12px var(--color-shadow);
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
@@ -384,7 +384,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: var(--color-overlay);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -392,7 +392,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
}
|
}
|
||||||
.modal-card {
|
.modal-card {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
@@ -413,9 +413,9 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.modal-btn {
|
.modal-btn {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ async function convertToTask() {
|
|||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -242,6 +242,6 @@ async function convertToTask() {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -147,10 +147,10 @@ function onOffsetUpdate(offset: number) {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.btn-new {
|
.btn-new {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ function onOffsetUpdate(offset: number) {
|
|||||||
.sort-select {
|
.sort-select {
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -175,7 +175,7 @@ function onOffsetUpdate(offset: number) {
|
|||||||
.sort-order {
|
.sort-order {
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -223,10 +223,10 @@ function onOffsetUpdate(offset: number) {
|
|||||||
}
|
}
|
||||||
.btn-cta {
|
.btn-cta {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.5rem 1.25rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ function cancelDelete() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 700px;
|
max-width: 720px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
@@ -360,7 +360,7 @@ function cancelDelete() {
|
|||||||
.settings-section {
|
.settings-section {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -387,7 +387,7 @@ function cancelDelete() {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -408,11 +408,11 @@ function cancelDelete() {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.btn-save {
|
.btn-save {
|
||||||
padding: 0.5rem 1.25rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -424,7 +424,7 @@ function cancelDelete() {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.saved-msg {
|
.saved-msg {
|
||||||
color: #22c55e;
|
color: var(--color-success);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -442,7 +442,7 @@ function cancelDelete() {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
@@ -489,7 +489,7 @@ function cancelDelete() {
|
|||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -505,7 +505,7 @@ function cancelDelete() {
|
|||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -521,7 +521,7 @@ function cancelDelete() {
|
|||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -556,13 +556,13 @@ function cancelDelete() {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
.btn-remove:hover:not(:disabled) {
|
.btn-remove:hover:not(:disabled) {
|
||||||
border-color: #ef4444;
|
border-color: var(--color-danger);
|
||||||
color: #ef4444;
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
.btn-remove:disabled {
|
.btn-remove:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
@@ -570,16 +570,16 @@ function cancelDelete() {
|
|||||||
}
|
}
|
||||||
.btn-confirm-delete {
|
.btn-confirm-delete {
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
background: #ef4444;
|
background: var(--color-danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.btn-confirm-delete:hover:not(:disabled) {
|
.btn-confirm-delete:hover:not(:disabled) {
|
||||||
background: #dc2626;
|
filter: brightness(0.9);
|
||||||
}
|
}
|
||||||
.btn-confirm-delete:disabled {
|
.btn-confirm-delete:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -590,7 +590,7 @@ function cancelDelete() {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,11 +336,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.btn-save {
|
.btn-save {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-save:disabled {
|
.btn-save:disabled {
|
||||||
@@ -348,18 +348,18 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-danger);
|
background: var(--color-danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.title-input {
|
.title-input {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
@@ -371,7 +371,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
@@ -390,7 +390,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@@ -403,7 +403,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
.preview-pane {
|
.preview-pane {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
}
|
}
|
||||||
@@ -414,7 +414,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
box-shadow: 0 4px 12px var(--color-shadow);
|
box-shadow: 0 4px 12px var(--color-shadow);
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
@@ -451,7 +451,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
.field-input {
|
.field-input {
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -459,7 +459,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: var(--color-overlay);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -467,7 +467,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
}
|
}
|
||||||
.modal-card {
|
.modal-card {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
@@ -488,9 +488,9 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.modal-btn {
|
.modal-btn {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ function onTagClick(tag: string) {
|
|||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -286,6 +286,6 @@ function onTagClick(tag: string) {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -184,10 +184,10 @@ function onOffsetUpdate(offset: number) {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.btn-new {
|
.btn-new {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ function onOffsetUpdate(offset: number) {
|
|||||||
.sort-select {
|
.sort-select {
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -219,7 +219,7 @@ function onOffsetUpdate(offset: number) {
|
|||||||
.sort-order {
|
.sort-order {
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--color-input-border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -267,10 +267,10 @@ function onOffsetUpdate(offset: number) {
|
|||||||
}
|
}
|
||||||
.btn-cta {
|
.btn-cta {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.5rem 1.25rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ async def send_message_route(conv_id: int):
|
|||||||
if not content:
|
if not content:
|
||||||
return jsonify({"error": "content is required"}), 400
|
return jsonify({"error": "content is required"}), 400
|
||||||
context_note_id = data.get("context_note_id")
|
context_note_id = data.get("context_note_id")
|
||||||
|
exclude_note_ids = data.get("exclude_note_ids") or []
|
||||||
|
|
||||||
# Save user message
|
# Save user message
|
||||||
await add_message(conv_id, "user", content, context_note_id=context_note_id)
|
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})
|
history.append({"role": msg.role, "content": msg.content})
|
||||||
|
|
||||||
# Build context with note search, URL fetching, etc.
|
# 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)
|
model = conv.model or await get_setting("default_model", Config.OLLAMA_MODEL)
|
||||||
|
|
||||||
async def generate():
|
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 = []
|
full_response = []
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_chat(messages, model):
|
async for chunk in stream_chat(messages, model):
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
|
import httpx
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.services.settings import get_all_settings, set_setting
|
from fabledassistant.services.settings import get_all_settings, set_setting
|
||||||
|
|
||||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
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"])
|
@settings_bp.route("", methods=["GET"])
|
||||||
async def get_settings_route():
|
async def get_settings_route():
|
||||||
settings = await get_all_settings()
|
settings = await get_all_settings()
|
||||||
@@ -16,6 +35,13 @@ async def update_settings_route():
|
|||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return jsonify({"error": "Expected a JSON object"}), 400
|
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():
|
for key, value in data.items():
|
||||||
await set_setting(key, str(value))
|
await set_setting(key, str(value))
|
||||||
settings = await get_all_settings()
|
settings = await get_all_settings()
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ async def create_conversation(title: str = "", model: str = "") -> Conversation:
|
|||||||
conv = Conversation(title=title, model=model)
|
conv = Conversation(title=title, model=model)
|
||||||
session.add(conv)
|
session.add(conv)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(conv)
|
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
|
||||||
# Initialize empty messages list for to_dict()
|
result = await session.execute(
|
||||||
conv.messages = []
|
select(Conversation)
|
||||||
return conv
|
.options(selectinload(Conversation.messages))
|
||||||
|
.where(Conversation.id == conv.id)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
async def get_conversation(conversation_id: int) -> Conversation | None:
|
async def get_conversation(conversation_id: int) -> Conversation | None:
|
||||||
|
|||||||
@@ -138,8 +138,14 @@ async def build_context(
|
|||||||
history: list[dict],
|
history: list[dict],
|
||||||
current_note_id: int | None,
|
current_note_id: int | None,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
) -> list[dict]:
|
exclude_note_ids: list[int] | None = None,
|
||||||
"""Build messages array for Ollama with system prompt and context."""
|
) -> 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")
|
assistant_name = await get_setting("assistant_name", "Fable")
|
||||||
system_parts = [
|
system_parts = [
|
||||||
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
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."
|
"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
|
# Include current note context if provided
|
||||||
if current_note_id:
|
if current_note_id:
|
||||||
note = await get_note(current_note_id)
|
note = await get_note(current_note_id)
|
||||||
if note:
|
if note:
|
||||||
|
context_meta["context_note_id"] = note.id
|
||||||
|
context_meta["context_note_title"] = note.title
|
||||||
system_parts.append(
|
system_parts.append(
|
||||||
f"\n\n--- Current Note ---\n"
|
f"\n\n--- Current Note ---\n"
|
||||||
f"Title: {note.title}\n"
|
f"Title: {note.title}\n"
|
||||||
@@ -158,28 +172,39 @@ async def build_context(
|
|||||||
f"--- End Note ---"
|
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)
|
keywords = _extract_keywords(user_message)
|
||||||
if keywords:
|
if keywords:
|
||||||
search_q = " ".join(keywords[:3])
|
seen_ids: set[int] = set()
|
||||||
try:
|
snippets: list[str] = []
|
||||||
notes, _ = await list_notes(q=search_q, limit=3)
|
for kw in keywords[:5]:
|
||||||
if notes:
|
if len(snippets) >= 3:
|
||||||
snippets = []
|
break
|
||||||
|
try:
|
||||||
|
notes, _ = await list_notes(q=kw, limit=3)
|
||||||
for n in notes:
|
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:
|
if current_note_id and n.id == current_note_id:
|
||||||
continue
|
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}")
|
snippets.append(f"- {n.title}: {body_preview}")
|
||||||
if snippets:
|
context_meta["auto_notes"].append({"id": n.id, "title": n.title})
|
||||||
system_parts.append(
|
if len(snippets) >= 3:
|
||||||
"\n\n--- Related Notes ---\n"
|
break
|
||||||
+ "\n".join(snippets)
|
except Exception:
|
||||||
+ "\n--- End Related Notes ---"
|
continue
|
||||||
)
|
if snippets:
|
||||||
except Exception:
|
system_parts.append(
|
||||||
pass
|
"\n\n--- Related Notes ---\n"
|
||||||
|
+ "\n".join(snippets)
|
||||||
|
+ "\n--- End Related Notes ---"
|
||||||
|
)
|
||||||
|
|
||||||
# Fetch URL content from user message
|
# Fetch URL content from user message
|
||||||
urls = _find_urls(user_message)
|
urls = _find_urls(user_message)
|
||||||
@@ -193,4 +218,4 @@ async def build_context(
|
|||||||
messages = [{"role": "system", "content": "".join(system_parts)}]
|
messages = [{"role": "system", "content": "".join(system_parts)}]
|
||||||
messages.extend(history)
|
messages.extend(history)
|
||||||
messages.append({"role": "user", "content": user_message})
|
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))
|
count_query = count_query.where(Note.status.is_(None))
|
||||||
|
|
||||||
if q:
|
if q:
|
||||||
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
terms = q.split()
|
||||||
pattern = f"%{escaped_q}%"
|
for term in terms:
|
||||||
filter_ = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
|
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
query = query.where(filter_)
|
pattern = f"%{escaped_term}%"
|
||||||
count_query = count_query.where(filter_)
|
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:
|
if tags:
|
||||||
for i, tag in enumerate(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.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## 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
|
## Project Overview
|
||||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
||||||
@@ -50,6 +50,10 @@ for AI-assisted features.
|
|||||||
`LIKE` prefix.
|
`LIKE` prefix.
|
||||||
- **Dark-first theming:** CSS custom properties on `:root` (light) and
|
- **Dark-first theming:** CSS custom properties on `:root` (light) and
|
||||||
`[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark.
|
`[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.
|
- **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
|
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`.
|
`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
|
need to POST the message body. Simpler than WebSockets; Quart supports async
|
||||||
generators natively for SSE.
|
generators natively for SSE.
|
||||||
- **Context building server-side:** Backend fetches URL content and searches notes —
|
- **Context building server-side:** Backend fetches URL content and searches notes —
|
||||||
frontend just sends the message text + optional note ID. Keyword extraction uses
|
frontend just sends the message text + optional note ID + optional exclude list.
|
||||||
simple word splitting with stopword filtering (no embeddings).
|
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
|
- **No blocking long-running operations:** Any potentially slow operation (model
|
||||||
pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the
|
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
|
UI. Backend uses SSE streaming for incremental responses (chat messages and model
|
||||||
@@ -185,7 +192,7 @@ fabledassistant/
|
|||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks
|
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks
|
||||||
│ │ ├── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
|
│ │ ├── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
|
||||||
│ │ ├── llm.py # Ollama interaction: ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, note search, URL fetching, 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
|
│ │ ├── 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
|
│ │ └── settings.py # Settings CRUD: get_setting, set_setting, get_all_settings
|
||||||
│ ├── utils/
|
│ ├── utils/
|
||||||
@@ -200,7 +207,7 @@ fabledassistant/
|
|||||||
│ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification; starts status polling + loads settings on mount
|
│ ├── App.vue # Shell: AppHeader + router-view + ChatPanel (slide-out) + ToastNotification; starts status polling + loads settings on mount
|
||||||
│ ├── main.ts # App init, imports theme.css
|
│ ├── main.ts # App init, imports theme.css
|
||||||
│ ├── assets/
|
│ ├── assets/
|
||||||
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset
|
│ │ └── theme.css # CSS custom properties: light/dark themes, body reset, design tokens (radius, semantic colors, focus ring, overlay), global focus-visible styles
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete + apiStreamPost (SSE via fetch + ReadableStream)
|
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete + apiStreamPost (SSE via fetch + ReadableStream)
|
||||||
│ ├── composables/
|
│ ├── composables/
|
||||||
@@ -209,19 +216,19 @@ fabledassistant/
|
|||||||
│ ├── stores/
|
│ ├── stores/
|
||||||
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
|
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags
|
||||||
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
|
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
|
||||||
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), saveMessageAsNote, summarizeAsNote, fetchModels, 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
|
│ │ ├── settings.ts # App settings: assistantName, defaultModel, fetchInstalledModels, pullModel (SSE streaming with onProgress callback), deleteModel
|
||||||
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
|
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
|
||||||
│ ├── types/
|
│ ├── types/
|
||||||
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
|
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
|
||||||
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, OllamaModel, OllamaStatus interfaces
|
│ │ ├── chat.ts # Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, OllamaStatus interfaces
|
||||||
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
|
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
|
||||||
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
|
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
|
||||||
│ ├── utils/
|
│ ├── utils/
|
||||||
│ │ ├── tags.ts # extractTags(), linkifyTags() (with (?<!&) to skip HTML entities), linkifyWikilinks()
|
│ │ ├── 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
|
│ │ └── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces ' after sanitization
|
||||||
│ ├── views/
|
│ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, backlinks
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks + Chat + Settings links, status indicator dot, chat panel toggle, theme toggle
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
|
||||||
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags
|
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags
|
||||||
@@ -275,7 +282,7 @@ fabledassistant/
|
|||||||
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
||||||
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
||||||
| PATCH | `/api/chat/conversations/:id` | Update conversation title (body: `{title}`) |
|
| 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/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 |
|
| 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}`) |
|
| 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 ✓
|
### 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] **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] **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] **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)
|
- [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] **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
|
- [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
|
### Phase 5 — Polish & Production Hardening
|
||||||
- [ ] Authentication (single-user or multi-user, TBD)
|
- [ ] Authentication (single-user or multi-user, TBD)
|
||||||
- [ ] Docker Swarm production stack (secrets, volumes, networking)
|
- [ ] 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?
|
- Authentication model: single-user (password-only) vs multi-user?
|
||||||
|
|
||||||
## Current Status
|
## 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
|
- 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`)
|
- Unified note/task model (a task is a note with `status IS NOT NULL`)
|
||||||
- LLM chat via Ollama with SSE streaming responses
|
- 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
|
- URL content fetching: detects URLs in messages, fetches and includes content
|
||||||
- Save assistant messages as notes; summarize entire conversations as notes
|
- 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
|
- Dedicated `/chat` page with bubble-style messages (user right, assistant left), floating dark input bar
|
||||||
|
|||||||
Reference in New Issue
Block a user