Project-aware assist, link suggestions, project-scoped RAG, semantic search tool, SSE race fix

- Writing assistant: inject project notes as context (definition-tagged first), wikilink suggestions
- Link suggestions: server-side endpoint finds unlinked term occurrences, NoteEditorView sidebar panel
- Project-scoped RAG: ChatView ProjectSelector filters semantic+keyword search to selected project
- Semantic search tool: LLM search_notes upgraded to hybrid semantic (0.40 threshold) + keyword merge
- SSE race condition fix: drain remaining events after stream loop exits in chat.py and notes.py
- RAG_AUTO_SNIPPET raised 800→4000; sidebar include uses full note body; MAX_BODY_CHARS 8000→24000
- Enter-to-submit on writing assistant instruction textareas (note and task editors)
- DiffView: equal-line collapsing with 3-line context around changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 14:02:54 -05:00
parent 9036dfd931
commit 48f070f773
22 changed files with 767 additions and 113 deletions
@@ -0,0 +1,16 @@
"""Add tags column to note_versions for complete version snapshots."""
from alembic import op
revision = "0023"
down_revision = "0022"
def upgrade() -> None:
op.execute(
"ALTER TABLE note_versions ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}'"
)
def downgrade() -> None:
op.execute("ALTER TABLE note_versions DROP COLUMN IF EXISTS tags")
+66 -8
View File
@@ -6,9 +6,52 @@ const props = defineProps<{
diff: DiffLine[];
}>();
const CONTEXT_LINES = 3;
const MIN_COLLAPSE = 6; // Only collapse if there are more than this many consecutive equal lines
type CollapseMarker = { type: 'collapse'; count: number };
type DisplayItem = DiffLine | CollapseMarker;
const insertions = computed(() => props.diff.filter(l => l.type === 'insert').length);
const deletions = computed(() => props.diff.filter(l => l.type === 'delete').length);
const displayItems = computed<DisplayItem[]>(() => {
const d = props.diff;
if (!d.length) return [];
// Mark which indices are within CONTEXT_LINES of a change
const shown = new Set<number>();
for (let i = 0; i < d.length; i++) {
if (d[i].type !== 'equal') {
for (let c = Math.max(0, i - CONTEXT_LINES); c <= Math.min(d.length - 1, i + CONTEXT_LINES); c++) {
shown.add(c);
}
}
}
// If nothing changed, show all
if (shown.size === 0) return d;
const result: DisplayItem[] = [];
let i = 0;
while (i < d.length) {
if (shown.has(i)) {
result.push(d[i]);
i++;
} else {
let count = 0;
while (i < d.length && !shown.has(i)) { count++; i++; }
if (count >= MIN_COLLAPSE) {
result.push({ type: 'collapse', count });
} else {
// Too short to collapse — show as-is
for (let k = i - count; k < i; k++) result.push(d[k]);
}
}
}
return result;
});
function markerFor(type: DiffLine['type']): string {
if (type === 'insert') return '+';
if (type === 'delete') return '';
@@ -24,14 +67,19 @@ function markerFor(type: DiffLine['type']): string {
</div>
<div class="diff-scroll">
<div v-if="!diff.length" class="diff-empty">No changes.</div>
<div
v-for="(line, i) in diff"
:key="i"
:class="['diff-line', `diff-${line.type}`]"
>
<span class="diff-marker">{{ markerFor(line.type) }}</span>
<span class="diff-text">{{ line.text }}</span>
</div>
<template v-for="(item, i) in displayItems" :key="i">
<div v-if="item.type === 'collapse'" class="diff-line diff-collapse">
<span class="diff-marker"></span>
<span class="diff-text">{{ item.count }} unchanged line{{ item.count !== 1 ? 's' : '' }}</span>
</div>
<div
v-else
:class="['diff-line', `diff-${item.type}`]"
>
<span class="diff-marker">{{ markerFor(item.type) }}</span>
<span class="diff-text">{{ item.text }}</span>
</div>
</template>
</div>
</div>
</template>
@@ -102,6 +150,16 @@ function markerFor(type: DiffLine['type']): string {
color: var(--color-text-muted);
}
.diff-collapse {
color: var(--color-text-muted);
opacity: 0.6;
font-style: italic;
border-top: 1px dashed var(--color-border);
border-bottom: 1px dashed var(--color-border);
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.diff-marker {
flex-shrink: 0;
width: 1rem;
+3 -2
View File
@@ -8,6 +8,7 @@ interface NoteVersion {
id: number;
note_id: number;
title: string;
tags: string[];
body?: string;
created_at: string;
}
@@ -18,7 +19,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: 'restore', body: string): void;
(e: 'restore', body: string, tags: string[]): void;
(e: 'close'): void;
}>();
@@ -101,7 +102,7 @@ async function selectVersionItem(v: NoteVersion) {
function restore() {
if (!selectedVersion.value?.body) return;
emit('restore', selectedVersion.value.body);
emit('restore', selectedVersion.value.body, selectedVersion.value.tags ?? []);
}
onMounted(loadVersions);
+56 -52
View File
@@ -53,7 +53,7 @@ function computeDiff(a: string, b: string): DiffLine[] {
return result;
}
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
const toast = useToastStore();
const state = ref<AssistState>("idle");
@@ -166,6 +166,17 @@ export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
}
}
function _buildProposedFullBody(fullText: string, wholeDoc: boolean): string {
if (wholeDoc) return fullText;
const t = target.value;
if (t) {
let text = fullText;
if (!text.endsWith("\n")) text += "\n";
return bodySnapshot.slice(0, t.startOffset) + text + bodySnapshot.slice(t.endOffset);
}
return fullText;
}
async function submit() {
if (!canSubmit.value) return;
@@ -185,71 +196,64 @@ export function useAssist(body: Ref<string>, noteId?: Ref<number | null>) {
target_section: targetSection,
instruction: instruction.value,
whole_doc: wholeDoc,
...(noteId?.value ? { note_id: noteId.value } : {}),
...(projectId?.value ? { project_id: projectId.value } : {}),
});
streamHandle = apiSSEStream("/api/notes/assist/stream", async (evt) => {
if (evt.event === "chunk") {
streamingText.value += evt.data.chunk as string;
}
if (evt.event === "done") {
const fullText = (evt.data.full_text as string) || streamingText.value;
proposedText.value = fullText;
// Stream with automatic reconnection on dropped connections
const MAX_RETRIES = 3;
let lastEventId = -1;
let gotDone = false;
if (wholeDoc) {
proposedFullBody.value = fullText;
} else {
// Reconstruct full body with section replaced
const t = target.value;
if (t) {
let text = fullText;
if (!text.endsWith("\n")) text += "\n";
proposedFullBody.value =
bodySnapshot.slice(0, t.startOffset) +
text +
bodySnapshot.slice(t.endOffset);
} else {
proposedFullBody.value = fullText;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 0) {
if (state.value !== "streaming") break;
await new Promise<void>((r) => setTimeout(r, Math.min(1000 * 2 ** (attempt - 1), 5000)));
if (state.value !== "streaming") break;
}
gotDone = false;
streamHandle = apiSSEStream(
`/api/notes/assist/stream${lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""}`,
(evt) => {
lastEventId = evt.id;
if (evt.event === "chunk") {
streamingText.value += evt.data.chunk as string;
}
}
if (evt.event === "done") {
gotDone = true;
const fullText = (evt.data.full_text as string) || streamingText.value;
proposedText.value = fullText;
proposedFullBody.value = _buildProposedFullBody(fullText, wholeDoc);
state.value = "review";
streamHandle = null;
_saveDraft();
}
if (evt.event === "error") {
gotDone = true;
error.value = evt.data.error as string;
toast.show("Assist failed: " + error.value, "error");
state.value = "idle";
streamHandle = null;
}
},
);
state.value = "review";
streamHandle = null;
await _saveDraft();
}
if (evt.event === "error") {
error.value = evt.data.error as string;
toast.show("Assist failed: " + error.value, "error");
state.value = "idle";
streamHandle = null;
}
});
await streamHandle.done;
await streamHandle.done;
streamHandle = null;
if (gotDone) break;
}
// If still streaming after all retries, treat accumulated text as result
if (state.value === "streaming") {
if (streamingText.value) {
proposedText.value = streamingText.value;
if (wholeDoc) {
proposedFullBody.value = streamingText.value;
} else {
const t = target.value;
if (t) {
let text = streamingText.value;
if (!text.endsWith("\n")) text += "\n";
proposedFullBody.value =
bodySnapshot.slice(0, t.startOffset) +
text +
bodySnapshot.slice(t.endOffset);
} else {
proposedFullBody.value = streamingText.value;
}
}
proposedFullBody.value = _buildProposedFullBody(streamingText.value, wholeDoc);
state.value = "review";
await _saveDraft();
} else {
state.value = "idle";
}
streamHandle = null;
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Request failed";
+34
View File
@@ -155,6 +155,7 @@ export const useChatStore = defineStore("chat", () => {
think = false,
contextNoteTitle?: string,
excludeNoteIds?: number[],
ragProjectId?: number | null,
) {
if (!currentConversation.value) return;
@@ -204,6 +205,7 @@ export const useChatStore = defineStore("chat", () => {
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
think,
rag_project_id: ragProjectId ?? undefined,
},
);
assistantMessageId = resp.assistant_message_id;
@@ -348,6 +350,37 @@ export const useChatStore = defineStore("chat", () => {
}
}
async function reconnectIfGenerating(convId: number): Promise<void> {
// Skip if a stream is already active for this conversation
if (isStreamingConv(convId)) return;
const conv = currentConversation.value;
if (!conv || conv.id !== convId) return;
// Find the last assistant message still in generating state
const lastMsg = [...conv.messages].reverse().find(
(m) => m.role === "assistant" && m.status === "generating"
);
if (!lastMsg) return;
// Remove the partial message — the done event will re-add the final version
conv.messages = conv.messages.filter((m) => m.id !== lastMsg.id);
const s = _getOrInitStream(convId);
s.streaming = true;
s.content = "";
s.thinking = "";
s.toolCalls = [];
s.status = "Reconnecting...";
s.pendingTool = null;
s.contextMeta = null;
// Reconnect from the beginning — the buffer replays all buffered events.
// If the buffer is gone (>60s stale), _streamGeneration will exhaust retries
// then fall back to fetchConversation to show the final saved content.
await _streamGeneration(convId, lastMsg.id);
}
async function confirmTool(confirmed: boolean) {
const convId = currentConversation.value?.id;
if (!convId) return;
@@ -461,6 +494,7 @@ export const useChatStore = defineStore("chat", () => {
deleteConversation,
updateTitle,
sendMessage,
reconnectIfGenerating,
confirmTool,
cancelGeneration,
saveMessageAsNote,
+1
View File
@@ -29,6 +29,7 @@ export interface NoteVersion {
id: number;
note_id: number;
title: string;
tags: string[];
body?: string;
created_at: string;
}
+39
View File
@@ -9,6 +9,7 @@ import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import type { Note } from "@/types/note";
import ProjectSelector from "@/components/ProjectSelector.vue";
const route = useRoute();
const router = useRouter();
@@ -46,6 +47,9 @@ const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null
// Note IDs excluded from auto-injection on next message
const excludedNoteIds = ref<number[]>([]);
// Project scope for RAG — when set, semantic & keyword search is restricted to this project
const ragProjectId = ref<number | null>(null);
let prevConvId: number | null = null;
const convId = computed(() => {
@@ -89,6 +93,8 @@ onMounted(async () => {
if (store.currentConversation?.id !== convId.value) {
await store.fetchConversation(convId.value);
}
// Reconnect if the last assistant message is still generating (e.g. after page refresh)
store.reconnectIfGenerating(convId.value);
} else {
await startNewConversation();
}
@@ -116,6 +122,8 @@ watch(convId, async (newId) => {
if (store.currentConversation?.id !== newId && !store.isStreamingConv(newId)) {
await store.fetchConversation(newId);
}
// Reconnect if a prior stream died while the backend was still generating
store.reconnectIfGenerating(newId);
scrollToBottom();
} else {
await startNewConversation();
@@ -220,6 +228,7 @@ async function sendMessage() {
true, // enable thinking in the full chat view
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
ragProjectId.value,
);
sending.value = false;
@@ -404,6 +413,14 @@ onUnmounted(() => {
<button class="btn-new-conv" @click="newConversation">
+ New Chat
</button>
<!-- RAG project scope -->
<div class="rag-scope-section">
<label class="rag-scope-label">Scope notes to project</label>
<ProjectSelector v-model="ragProjectId" />
<p v-if="ragProjectId" class="rag-scope-hint">RAG search restricted to this project</p>
</div>
<div class="conv-list">
<div
v-for="conv in store.conversations"
@@ -713,6 +730,28 @@ onUnmounted(() => {
opacity: 0.9;
}
.rag-scope-section {
padding: 0.5rem 0.75rem 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.rag-scope-label {
display: block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.3rem;
}
.rag-scope-hint {
margin: 0.3rem 0 0;
font-size: 0.7rem;
color: var(--color-primary);
font-style: italic;
}
.conv-list {
flex: 1;
overflow-y: auto;
+155 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, nextTick } from "vue";
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
@@ -43,7 +43,7 @@ const isEditing = computed(() => noteId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist — pass noteId for draft persistence
const assist = useAssist(body, noteId);
const assist = useAssist(body, noteId, projectId);
// Scope selector options: "Whole document" + one per section
const scopeOptions = computed(() => {
@@ -155,6 +155,74 @@ function dismissTagSuggestions() {
appliedTags.value = new Set();
}
// ── Link Suggestions ────────────────────────────────────────────────────────
interface LinkSuggestion {
note_id: number;
title: string;
count: number;
}
const linkSuggestions = ref<LinkSuggestion[]>([]);
let linkCheckTimer: ReturnType<typeof setTimeout> | null = null;
async function fetchLinkSuggestions() {
if (!projectId.value || !body.value || !noteId.value) {
linkSuggestions.value = [];
return;
}
try {
const res = await apiPost<{ suggestions: LinkSuggestion[] }>('/api/notes/link-suggestions', {
body: body.value,
project_id: projectId.value,
exclude_note_id: noteId.value,
});
linkSuggestions.value = res.suggestions;
} catch {
linkSuggestions.value = [];
}
}
function scheduleLinkCheck() {
if (linkCheckTimer) clearTimeout(linkCheckTimer);
linkCheckTimer = setTimeout(fetchLinkSuggestions, 2000);
}
watch(body, () => {
if (projectId.value) scheduleLinkCheck();
});
watch(projectId, (pid) => {
if (pid) fetchLinkSuggestions();
else linkSuggestions.value = [];
});
/** Replace unlinked occurrences of title in body with [[title]]. */
function applyWikilink(text: string, title: string): string {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = text.split(/(\[\[[^\]]*\]\])/);
const pattern = new RegExp(`\\b${escaped}\\b`, 'gi');
return parts.map((part, i) =>
i % 2 === 0 ? part.replace(pattern, `[[${title}]]`) : part
).join('');
}
function applyLink(title: string) {
body.value = applyWikilink(body.value, title);
markDirty();
linkSuggestions.value = linkSuggestions.value.filter(s => s.title !== title);
}
function applyAllLinks() {
let text = body.value;
for (const s of linkSuggestions.value) {
text = applyWikilink(text, s.title);
}
body.value = text;
markDirty();
linkSuggestions.value = [];
}
// Track saved state for dirty detection
let savedTitle = "";
let savedBody = "";
@@ -199,6 +267,9 @@ onMounted(async () => {
} catch {
// No draft — normal
}
// Initial link suggestions
fetchLinkSuggestions();
}
});
@@ -306,6 +377,9 @@ function onBeforeUnload(e: BeforeUnloadEvent) {
}
onMounted(() => window.addEventListener("beforeunload", onBeforeUnload));
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
// Close any in-flight assist SSE stream when navigating away
onUnmounted(() => assist.clearSelection());
</script>
<template>
@@ -425,6 +499,21 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</template>
</div>
<!-- Link Suggestions -->
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
<div class="sb-label-row">
<span class="sb-label">Link Suggestions</span>
<button class="btn-link-all" @click="applyAllLinks">Link all</button>
</div>
<div class="link-suggest-list">
<div v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-item">
<span class="link-suggest-title">[[{{ s.title }}]]</span>
<span class="link-suggest-count">×{{ s.count }}</span>
<button class="btn-apply-link" @click="applyLink(s.title)">Link</button>
</div>
</div>
</div>
<div class="sb-divider"></div>
<!-- Writing Assistant (always visible) -->
@@ -451,6 +540,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
placeholder="What should I do?"
class="assist-instruction"
rows="3"
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
></textarea>
<div class="assist-input-actions">
<button
@@ -500,7 +590,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
v-if="showHistory && noteId"
:note-id="noteId"
:current-body="body"
@restore="body = $event; markDirty()"
@restore="(b: string, t: string[]) => { body = b; tags = t; markDirty(); }"
@close="showHistory = false"
/>
@@ -648,6 +738,68 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
align-items: center;
}
/* Link Suggestions */
.link-suggest-field { gap: 0.4rem; }
.sb-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-link-all {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
background: none;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
color: var(--color-primary);
cursor: pointer;
font-family: inherit;
}
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
.link-suggest-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.link-suggest-item {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
}
.link-suggest-title {
flex: 1;
font-family: monospace;
color: var(--color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-suggest-count {
color: var(--color-text-muted);
font-size: 0.72rem;
flex-shrink: 0;
}
.btn-apply-link {
flex-shrink: 0;
font-size: 0.72rem;
padding: 0.1rem 0.4rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
color: var(--color-text-secondary);
}
.btn-apply-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* Writing Assistant section */
.assist-section {
display: flex;
+2 -1
View File
@@ -107,7 +107,7 @@ const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist
const assist = useAssist(body);
const assist = useAssist(body, taskId, projectId);
const assistLabel = computed(() => {
if (assist.isProofreading.value) return "Proofreading document...";
@@ -681,6 +681,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
placeholder="What should I do with this section?"
class="assist-instruction"
rows="3"
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
></textarea>
<div class="assist-input-actions">
<button class="btn-generate" @click="assist.submit()" :disabled="!assist.canSubmit.value">Generate</button>
+7
View File
@@ -33,6 +33,13 @@ def create_app() -> Quart:
level=getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO),
)
# Validate config early so misconfigurations surface immediately with clear messages
try:
Config.validate()
except ValueError as exc:
logging.getLogger(__name__).error("Invalid configuration:\n%s", exc)
raise
app = Quart(__name__, static_folder=None)
app.secret_key = Config.SECRET_KEY
app.config["SESSION_COOKIE_HTTPONLY"] = True
+17
View File
@@ -73,3 +73,20 @@ class Config:
@classmethod
def searxng_enabled(cls) -> bool:
return bool(cls.SEARXNG_URL)
@classmethod
def validate(cls) -> None:
"""Validate critical config values at startup. Raises ValueError on misconfiguration."""
errors: list[str] = []
if cls.OLLAMA_NUM_CTX < 512:
errors.append(f"OLLAMA_NUM_CTX={cls.OLLAMA_NUM_CTX} is too small (minimum 512)")
if cls.LOG_RETENTION_DAYS < 1:
errors.append(f"LOG_RETENTION_DAYS={cls.LOG_RETENTION_DAYS} must be >= 1")
if cls.IMAGE_MAX_BYTES < 1024:
errors.append(f"IMAGE_MAX_BYTES={cls.IMAGE_MAX_BYTES} must be >= 1024")
if not (1 <= cls.SMTP_PORT <= 65535):
errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535")
if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")):
errors.append(f"BASE_URL='{cls.BASE_URL}' must start with http:// or https:// when OIDC is enabled")
if errors:
raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))
+3 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import ARRAY, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
@@ -14,6 +14,7 @@ class NoteVersion(Base):
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
body: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -24,6 +25,7 @@ class NoteVersion(Base):
"note_id": self.note_id,
"user_id": self.user_id,
"title": self.title,
"tags": self.tags or [],
"created_at": self.created_at.isoformat(),
}
if include_body:
+11 -1
View File
@@ -116,6 +116,7 @@ async def send_message_route(conv_id: int):
include_note_ids = data.get("include_note_ids") or []
excluded_note_ids = data.get("excluded_note_ids") or []
think = bool(data.get("think", False))
rag_project_id = data.get("rag_project_id") or None
# Reject if generation already running for this conversation
existing = get_buffer(conv_id)
@@ -128,7 +129,10 @@ async def send_message_route(conv_id: int):
# Create placeholder assistant message
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
try:
buf = create_buffer(conv_id, assistant_msg.id)
except RuntimeError:
return jsonify({"error": "Generation already in progress"}), 409
# Build history from existing messages (excluding system and the placeholder)
history = []
@@ -146,6 +150,7 @@ async def send_message_route(conv_id: int):
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
think=think,
rag_project_id=rag_project_id,
))
return jsonify({
@@ -192,6 +197,11 @@ async def generation_stream_route(conv_id: int):
if not got_new:
yield ": keepalive\n\n"
# Final drain: deliver any events appended between the last yield
# and the state check above (e.g. the 'done' event).
for event in buf.events_after(cursor):
yield buf.format_sse(event)
return Response(
stream(),
content_type="text/event-stream",
+114 -3
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import re
from datetime import date
from fabledassistant.services.embeddings import upsert_note_embedding
@@ -227,7 +228,10 @@ async def patch_note_route(note_id: int):
if key in data:
fields[key] = data[key]
if "due_date" in data:
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None
try:
fields["due_date"] = date.fromisoformat(data["due_date"]) if data["due_date"] else None
except (ValueError, TypeError):
return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400
if "tags" in data:
fields["tags"] = data["tags"]
note = await update_note(uid, note_id, **fields)
@@ -286,16 +290,61 @@ async def assist_route():
body = data.get("body", "")
target_section = data.get("target_section", "")
instruction = data.get("instruction", "")
whole_doc = bool(data.get("whole_doc", False))
project_id = data.get("project_id")
note_id = data.get("note_id")
if not whole_doc and not target_section:
return jsonify({"error": "target_section is required for section mode"}), 400
if not instruction:
return jsonify({"error": "instruction is required"}), 400
# Fetch related project notes for context (up to 5, excluding current note).
# Glossary-tagged notes are prioritised so the model knows canonical definitions.
context_notes: list[dict] = []
if project_id:
try:
pid = int(project_id)
# 1) Glossary notes first (up to 3)
glossary_notes, _ = await list_notes(
uid, project_id=pid, tags=["definition"], sort="updated_at", order="desc", limit=3
)
seen_ids: set[int] = set()
for n in glossary_notes:
if n.id == note_id:
continue
seen_ids.add(n.id)
context_notes.append({
"title": n.title or "Untitled",
"tags": n.tags or [],
"body": n.body or "",
})
# 2) Fill remaining slots with recently-updated notes
if len(context_notes) < 5:
recent_notes, _ = await list_notes(
uid, project_id=pid, sort="updated_at", order="desc",
limit=5 - len(context_notes) + len(seen_ids) + 1,
)
for n in recent_notes:
if n.id == note_id or n.id in seen_ids:
continue
if len(context_notes) >= 5:
break
seen_ids.add(n.id)
context_notes.append({
"title": n.title or "Untitled",
"tags": n.tags or [],
"body": n.body or "",
})
except Exception:
logger.warning("Failed to fetch project context notes for assist", exc_info=True)
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
messages = build_assist_messages(body, target_section, instruction, whole_doc=whole_doc)
messages = build_assist_messages(
body, target_section, instruction,
whole_doc=whole_doc,
context_notes=context_notes or None,
)
buf = create_assist_buffer(uid)
asyncio.create_task(run_assist_generation(buf, messages, model))
@@ -334,6 +383,11 @@ async def assist_stream_route():
if not got_new:
yield ": keepalive\n\n"
# Final drain: deliver any events appended between the last yield
# and the state check above (e.g. the 'done' event).
for event in buf.events_after(cursor):
yield buf.format_sse(event)
return Response(
stream(),
content_type="text/event-stream",
@@ -344,6 +398,63 @@ async def assist_stream_route():
)
# ── Link suggestions ─────────────────────────────────────────────────────────
_WIKILINK_RE = re.compile(r'\[\[[^\]]+\]\]')
def _find_unlinked_terms(body: str, note_titles: list[tuple[int, str]]) -> list[dict]:
"""Return project note titles that appear in body as plain text (not inside [[...]])."""
linked_ranges = [(m.start(), m.end()) for m in _WIKILINK_RE.finditer(body)]
def in_wikilink(start: int, end: int) -> bool:
return any(ls <= start and end <= le for ls, le in linked_ranges)
suggestions = []
for note_id, title in note_titles:
title = title.strip()
if len(title) < 3:
continue
pattern = re.compile(r'\b' + re.escape(title) + r'\b', re.IGNORECASE)
count = sum(1 for m in pattern.finditer(body) if not in_wikilink(m.start(), m.end()))
if count > 0:
suggestions.append({"note_id": note_id, "title": title, "count": count})
suggestions.sort(key=lambda x: x["count"], reverse=True)
return suggestions
@notes_bp.route("/link-suggestions", methods=["POST"])
@login_required
async def link_suggestions_route():
"""Find project note titles that appear unlinked in the given body text."""
uid = get_current_user_id()
data = await request.get_json()
body_text = data.get("body", "")
project_id = data.get("project_id")
exclude_note_id = data.get("exclude_note_id")
if not project_id or not body_text:
return jsonify({"suggestions": []})
try:
project_notes, _ = await list_notes(
uid, project_id=int(project_id), sort="title", order="asc", limit=200
)
titles = [
(n.id, n.title)
for n in project_notes
if n.id != exclude_note_id and n.title
]
suggestions = _find_unlinked_terms(body_text, titles)
except Exception:
logger.warning("Failed to compute link suggestions", exc_info=True)
suggestions = []
return jsonify({"suggestions": suggestions})
# ── Draft routes ─────────────────────────────────────────────────────────────
@notes_bp.route("/<int:note_id>/draft", methods=["GET"])
+43 -5
View File
@@ -1,18 +1,50 @@
MAX_BODY_CHARS = 8000
MAX_BODY_CHARS = 24000
MAX_CONTEXT_NOTE_CHARS = 1500
def _build_context_block(context_notes: list[dict]) -> str:
"""Format project context notes into a prompt block."""
lines = [
"--- Project Context ---",
"The following notes are from the same project. Notes tagged 'definition' are "
"canonical term definitions — treat them as authoritative. Use them to maintain "
"consistency in terminology, tone, style, and [[wikilinks]] to other documents.",
"",
]
for n in context_notes:
tags_str = f" [tags: {', '.join(n['tags'])}]" if n.get("tags") else ""
body_preview = (n["body"] or "")[:MAX_CONTEXT_NOTE_CHARS]
if len(n["body"] or "") > MAX_CONTEXT_NOTE_CHARS:
body_preview += ""
lines.append(f"**{n['title']}**{tags_str}")
if body_preview:
lines.append(body_preview)
lines.append("")
lines.append("--- End Project Context ---")
return "\n".join(lines)
def build_assist_messages(
body: str, target_section: str, instruction: str, whole_doc: bool = False
body: str,
target_section: str,
instruction: str,
whole_doc: bool = False,
context_notes: list[dict] | None = None,
) -> list[dict]:
"""Build Ollama messages for writing assist.
When whole_doc=True, the model revises the entire document.
When whole_doc=False (section mode), the full body is provided as read-only
context and the model outputs only the replacement for the target section.
context_notes: optional list of {title, tags, body} dicts from the same project,
injected so the model can maintain consistency with related documents.
"""
truncated_body = body[:MAX_BODY_CHARS]
if len(body) > MAX_BODY_CHARS:
truncated_body += "\n... (truncated)"
truncated_body += "\n (truncated)"
context_block = (_build_context_block(context_notes) + "\n\n") if context_notes else ""
if whole_doc:
system_content = (
@@ -21,9 +53,12 @@ def build_assist_messages(
"content not addressed by the instruction. "
"Preserve all markdown formatting exactly — including bullet lists (- item), "
"numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
"italic (_text_), and code blocks. Never flatten nested lists into plain text."
"italic (_text_), and code blocks. Never flatten nested lists into plain text. "
"When referencing concepts defined in the Project Context, use [[wikilinks]] "
"to link to the relevant note by its exact title."
)
user_content = (
f"{context_block}"
f"--- Document ---\n{truncated_body}\n--- End Document ---\n\n"
f"Instruction: {instruction}"
)
@@ -41,9 +76,12 @@ def build_assist_messages(
"Match the document's existing tone and style. "
"IMPORTANT: Preserve all markdown formatting exactly — including bullet lists (- item), "
"numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
"italic (_text_), and code blocks. Never flatten nested lists into plain text."
"italic (_text_), and code blocks. Never flatten nested lists into plain text. "
"When referencing concepts defined in the Project Context, use [[wikilinks]] "
"to link to the relevant note by its exact title."
)
user_content = (
f"{context_block}"
f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n"
f"Instruction: {instruction}"
)
@@ -79,6 +79,7 @@ async def semantic_search_notes(
exclude_ids: set[int] | None = None,
limit: int = 8,
threshold: float = _SIMILARITY_THRESHOLD,
project_id: int | None = None,
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
@@ -99,6 +100,8 @@ async def semantic_search_notes(
.join(Note, NoteEmbedding.note_id == Note.id)
.where(NoteEmbedding.user_id == user_id)
)
if project_id is not None:
stmt = stmt.where(Note.project_id == project_id)
if exclude_ids:
stmt = stmt.where(NoteEmbedding.note_id.notin_(exclude_ids))
rows = list((await session.execute(stmt)).all())
@@ -45,7 +45,7 @@ _TOOL_LABELS: dict[str, str] = {
"get_note": "Reading note",
"list_notes": "Listing notes",
"list_tasks": "Searching tasks",
"search_notes": "Searching notes",
"search_notes": "Searching notes (semantic)",
"create_event": "Creating calendar event",
"list_events": "Searching calendar",
"search_events": "Searching calendar",
@@ -149,6 +149,7 @@ async def run_generation(
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
think: bool = False,
rag_project_id: int | None = None,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
MAX_TOOL_ROUNDS = 5
@@ -172,13 +173,14 @@ async def run_generation(
history_to_use, history_summary = await summarize_history_for_context(history, model)
# Phase 3: Build context and wait for model in parallel.
model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=90.0))
model_load_task = asyncio.create_task(wait_for_model_loaded(model, timeout=180.0))
context_task = asyncio.create_task(build_context(
user_id, history_to_use, context_note_id, user_content,
history_summary=history_summary,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
rag_project_id=rag_project_id,
))
messages, context_meta = await context_task
@@ -192,7 +194,7 @@ async def run_generation(
buf.append_event("status", {"status": "Loading model..."})
loaded = await model_load_task
if not loaded:
logger.warning("Model %s did not load within 90s — proceeding anyway", model)
logger.warning("Model %s did not load within 180s — proceeding anyway", model)
t_start = time.monotonic()
timing: dict = {
@@ -415,6 +417,9 @@ async def run_assist_generation(
On each retry the accumulated content is reset so the done event
always reflects only the successful generation.
"""
input_chars = sum(len(m.get("content", "")) for m in messages)
logger.info("Assist generation started: model=%s, input_chars=%d", model, input_chars)
last_exc: BaseException | None = None
for attempt in range(3):
if attempt > 0:
@@ -429,9 +434,15 @@ async def run_assist_generation(
buf.content_so_far += chunk
buf.append_event("chunk", {"chunk": chunk})
output_chars = len(buf.content_so_far)
logger.info(
"Assist generation complete: output_chars=%d, events=%d",
output_chars, len(buf.events),
)
buf.state = GenerationState.COMPLETED
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "full_text": buf.content_so_far})
logger.info("Assist done event appended (event index %d)", len(buf.events) - 1)
return
except httpx.HTTPStatusError as exc:
+35 -5
View File
@@ -1,11 +1,14 @@
import asyncio
import ipaddress
import json
import logging
import re
import socket
import time
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from typing import Literal
from urllib.parse import urlparse
import httpx
@@ -29,7 +32,7 @@ STOP_WORDS = frozenset({
RAG_AUTO_THRESHOLD = 0.60
RAG_AUTO_LIMIT = 3
RAG_AUTO_SNIPPET = 800
RAG_AUTO_SNIPPET = 4000
async def get_installed_models() -> set[str]:
@@ -268,11 +271,32 @@ async def generate_completion(
raise last_exc
def _is_private_url(url: str) -> bool:
"""Return True if the URL resolves to a private/loopback/link-local address (SSRF guard)."""
try:
host = urlparse(url).hostname
if not host:
return True
if host.lower() in ("localhost", "::1"):
return True
addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
for entry in addr_info:
ip = ipaddress.ip_address(entry[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return True
return False
except Exception:
return True # Block on resolution failure
async def fetch_url_content(url: str) -> str:
"""Fetch a URL and return text content (HTML tags stripped)."""
if _is_private_url(url):
logger.warning("Blocked fetch of private/internal URL: %s", url)
return "[URL blocked: internal network access not permitted]"
try:
async with httpx.AsyncClient(
timeout=15.0, follow_redirects=True, headers={"User-Agent": "FabledAssistant/1.0"}
timeout=15.0, follow_redirects=False, headers={"User-Agent": "FabledAssistant/1.0"}
) as client:
resp = await client.get(url)
resp.raise_for_status()
@@ -426,6 +450,7 @@ async def build_context(
history_summary: str | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
rag_project_id: int | None = None,
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
@@ -465,6 +490,9 @@ async def build_context(
"If a note was created earlier in the conversation and the user provides more content for it, use update_note. "
"Use get_note to read the full content of a specific note. "
"Use list_notes to browse notes by recency or tag. "
"Use search_notes for conceptual/semantic queries — e.g. 'what notes do I have about X' or "
"'find notes related to Y' — it uses semantic understanding to find thematically related content "
"even when exact words don't match. Pass project= to scope the search to a specific project. "
"Use delete_note / delete_task only when explicitly asked to delete — these require confirmation."
)
tool_guidance = "\n".join(tool_lines)
@@ -511,7 +539,8 @@ async def build_context(
try:
from fabledassistant.services.embeddings import semantic_search_notes
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=rag_project_id,
):
found_scored.append((score, note))
except Exception:
@@ -522,7 +551,8 @@ async def build_context(
if keywords:
try:
for note in await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=8
user_id, keywords, exclude_ids=search_exclude or None, limit=8,
project_id=rag_project_id,
):
found_scored.append((None, note))
except Exception:
@@ -587,7 +617,7 @@ async def build_context(
try:
n = await _get_note(user_id, nid)
if n:
body_preview = n.body[:2000] if n.body else ""
body_preview = n.body or ""
included_snippets.append(f"- {n.title}: {body_preview}")
except Exception:
logger.warning("Failed to load included note %d for context", nid, exc_info=True)
@@ -6,9 +6,11 @@ from fabledassistant.models.note_version import NoteVersion
MAX_VERSIONS = 20
async def create_version(user_id: int, note_id: int, body: str, title: str) -> NoteVersion:
async def create_version(
user_id: int, note_id: int, body: str, title: str, tags: list[str] | None = None
) -> NoteVersion:
async with async_session() as session:
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title)
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title, tags=tags or [])
session.add(version)
await session.commit()
await session.refresh(version)
+22 -13
View File
@@ -197,6 +197,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
# Snapshot before changes for version creation
old_body = note.body
old_title = note.title
old_tags = list(note.tags or [])
for key, value in fields.items():
if not hasattr(note, key):
continue
@@ -214,7 +215,7 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
# Create a version snapshot when body actually changes
if "body" in fields and fields["body"] != old_body:
from fabledassistant.services.note_versions import create_version
await create_version(user_id, note_id, old_body, old_title)
await create_version(user_id, note_id, old_body, old_title, old_tags)
return note
@@ -234,19 +235,24 @@ async def delete_note(user_id: int, note_id: int) -> bool:
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
async with async_session() as session:
result = await session.execute(
text(
"SELECT DISTINCT unnest(tags) AS tag FROM notes"
" WHERE tags != '{}' AND user_id = :user_id"
).bindparams(user_id=user_id)
)
all_tags = [row[0] for row in result.fetchall()]
if q:
q_lower = q.lower()
all_tags = [t for t in all_tags if q_lower in t.lower()]
return sorted(all_tags)
result = await session.execute(
text(
"SELECT DISTINCT tag FROM"
" (SELECT unnest(tags) AS tag FROM notes"
" WHERE tags != '{}' AND user_id = :user_id) t"
" WHERE tag ILIKE :q_pattern ORDER BY tag LIMIT 100"
).bindparams(user_id=user_id, q_pattern=f"%{q}%")
)
else:
result = await session.execute(
text(
"SELECT DISTINCT unnest(tags) AS tag FROM notes"
" WHERE tags != '{}' AND user_id = :user_id"
" ORDER BY tag LIMIT 100"
).bindparams(user_id=user_id)
)
return [row[0] for row in result.fetchall()]
async def convert_note_to_task(user_id: int, note_id: int) -> Note:
@@ -291,6 +297,7 @@ async def search_notes_for_context(
keywords: list[str],
exclude_ids: set[int] | None = None,
limit: int = 3,
project_id: int | None = None,
) -> list[Note]:
"""Search notes by keywords with OR logic. Optimized for context building — no count query."""
async with async_session() as session:
@@ -301,6 +308,8 @@ async def search_notes_for_context(
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters))
if project_id is not None:
query = query.where(Note.project_id == project_id)
if exclude_ids:
query = query.where(Note.id.notin_(exclude_ids))
query = query.order_by(Note.updated_at.desc()).limit(limit)
+75 -13
View File
@@ -182,19 +182,29 @@ _CORE_TOOLS = [
"type": "function",
"function": {
"name": "search_notes",
"description": "Search the user's notes and tasks. Use this when the user asks about their existing notes, tasks, or wants to find something they've written.",
"description": (
"Search notes and tasks using semantic understanding. Finds content related to a concept, "
"theme, or topic — even when exact keywords don't appear in the title or body. "
"Use this when the user asks what notes they have about a topic, wants to explore related "
"content, or is looking for something they've written. Falls back to keyword search "
"automatically if semantic search is unavailable."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query to find notes/tasks",
"description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords",
},
"type": {
"type": "string",
"enum": ["note", "task"],
"description": "Restrict results to only notes or only tasks. Omit to search both.",
},
"project": {
"type": "string",
"description": "Optional project name to restrict search to notes in that project",
},
},
"required": ["query"],
},
@@ -1070,27 +1080,79 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "search_notes":
query = arguments.get("query", "")
type_filter = arguments.get("type")
project_name = arguments.get("project")
is_task: bool | None = None
if type_filter == "note":
is_task = False
elif type_filter == "task":
is_task = True
notes, total = await list_notes(user_id=user_id, q=query, is_task=is_task, limit=5)
results = []
for n in notes:
results.append({
"id": n.id,
"title": n.title,
"type": "task" if n.status is not None else "note",
"status": n.status,
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
})
# Resolve project name → id
search_project_id: int | None = None
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt2, list_projects as _lp2
proj = await _gpbt2(user_id, project_name)
if proj is None:
all_p = await _lp2(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj:
search_project_id = proj.id
results_map: dict[int, dict] = {}
# 1) Semantic search — broad threshold (0.40) since this is an intentional query
try:
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
sem_results = await _ssn(
user_id, query, limit=8, threshold=0.40,
project_id=search_project_id,
)
for score, n in sem_results:
n_is_task = n.status is not None
if is_task is True and not n_is_task:
continue
if is_task is False and n_is_task:
continue
results_map[n.id] = {
"id": n.id,
"title": n.title,
"type": "task" if n_is_task else "note",
"status": n.status,
"relevance": f"{round(score * 100)}%",
"preview": (n.body[:300] + "") if n.body and len(n.body) > 300 else (n.body or ""),
}
logger.debug("search_notes semantic: %d results for %r", len(results_map), query)
except Exception:
logger.debug("Semantic search unavailable in search_notes, using keyword only", exc_info=True)
# 2) Keyword search — merge in anything not already found
try:
kw_notes, _ = await list_notes(
user_id=user_id, q=query, is_task=is_task,
project_id=search_project_id, limit=8,
)
for n in kw_notes:
if n.id not in results_map:
results_map[n.id] = {
"id": n.id,
"title": n.title,
"type": "task" if n.status is not None else "note",
"status": n.status,
"relevance": "keyword",
"preview": (n.body[:300] + "") if n.body and len(n.body) > 300 else (n.body or ""),
}
except Exception:
logger.warning("Keyword fallback in search_notes failed", exc_info=True)
results = list(results_map.values())[:8]
return {
"success": True,
"type": "search",
"data": {
"query": query,
"total": total,
"count": len(results),
"results": results,
},
}
+47 -1
View File
@@ -12,7 +12,53 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-03-05Note editor sidebar layout, full-document writing assist, persistent drafts, version history, context window bump.
2026-03-06Project-aware writing assist, link suggestions, project-scoped RAG, semantic note search tool, SSE done-event race fix, RAG snippet increase, sidebar include full body, Enter-to-submit assist.
**SSE done-event race fix:** `routes/notes.py` and `routes/chat.py` — The SSE stream generator had a race condition where `yield buf.format_sse(event)` hands control back to the event loop, allowing the generation task to set `buf.state = COMPLETED` and append the `done` event between the last yield and the state check. The loop would then break without delivering the `done` event (containing `full_text`). Fixed by adding a final drain loop after the `while` exits in both stream generators. Added INFO-level logging to `run_assist_generation` showing `input_chars`, `output_chars`, event count, and done-event index.
**RAG snippet + sidebar include:** `services/llm.py``RAG_AUTO_SNIPPET` raised from 800 to 4000 chars. Sidebar-included notes (`include_note_ids`) now deliver full body (`n.body or ""`) instead of the previous 2000-char truncation, matching the paperclip/context_note behaviour.
**Assist `MAX_BODY_CHARS`:** `services/assist.py` — raised from 8000 to 24000 chars, safely within the 16384-token Ollama context (input + output headroom considered).
**DiffView equal-line collapsing:** `DiffView.vue` — unchanged lines are now collapsed into `⋯ N unchanged lines` markers when a run of 6+ equal lines exists, showing only 3 lines of context around each change (git-style). Makes reviewing large-document diffs practical.
**Enter-to-submit assist textarea:** Both `NoteEditorView.vue` and `TaskEditorView.vue` — writing assistant instruction textareas now submit on plain Enter (Shift+Enter inserts newline). Uses `@keydown.enter.exact.prevent` with `canSubmit` guard.
**Project-aware writing assist:** `services/assist.py``build_assist_messages()` accepts `context_notes: list[dict] | None`; when provided, injects a `--- Project Context ---` block into the user message (before the document) listing related note titles, tags, and body previews. Instructs the model to use `[[wikilinks]]` for referenced concepts. `routes/notes.py` `assist_route` — accepts `project_id` and `note_id`; fetches up to 5 project notes as context: `definition`-tagged notes first (up to 3, always injected for canonical term definitions), then recently-updated notes fill remaining slots. `composables/useAssist.ts` — accepts `projectId?: Ref<number | null>` third param; includes `note_id` and `project_id` in the POST body. Both `NoteEditorView.vue` and `TaskEditorView.vue` pass their project ref to `useAssist`.
**Link suggestions:** `routes/notes.py` — new `POST /api/notes/link-suggestions` endpoint. Accepts `{body, project_id, exclude_note_id}`. Fetches all project note titles (up to 200), runs regex to find titles appearing as plain text outside `[[...]]` spans, returns `[{note_id, title, count}]` sorted by occurrence count. `NoteEditorView.vue` — new "Link Suggestions" sidebar section: fetches suggestions on mount and debounced 2s after each body edit (when project is set). Per-term "Link" button applies `[[Title]]` wrapping to all unlinked occurrences; "Link all" applies all at once. Text replacement splits on `[[...]]` spans to avoid double-linking.
**Project-scoped RAG:** `services/embeddings.py` `semantic_search_notes()` — new `project_id` param filters the SQL join to `Note.project_id == project_id`. `services/notes.py` `search_notes_for_context()` — same `project_id` filter. `services/llm.py` `build_context()` — new `rag_project_id` param threaded to both search functions. `services/generation_task.py` `run_generation()` + `routes/chat.py` `send_message_route``rag_project_id` accepted from POST body and passed through. `stores/chat.ts` `sendMessage()` — new `ragProjectId` param included in POST. `ChatView.vue``ProjectSelector` added to chat sidebar under "Scope notes to project" label; when set, all RAG (semantic + keyword fallback) restricts to that project's notes.
**Semantic note search tool:** `services/tools.py` `search_notes` — upgraded from pure keyword to hybrid semantic+keyword. Runs `semantic_search_notes()` first (threshold 0.40, broader than auto-inject 0.60 since this is an intentional query); merges keyword results for any not already found. Results include a `relevance` field (percentage for semantic, `"keyword"` for text matches). Added `project` parameter (resolves by name, same pattern as `list_notes`). Preview extended to 300 chars. Tool description updated to explain semantic capability. `services/llm.py` tool guidance updated to direct the model to use `search_notes` for conceptual/thematic queries. `generation_task.py` status label updated to "Searching notes (semantic)".
**Previous session (2026-03-06):** Code review fixes: SSRF guard, config validation, race condition handling, tag autocomplete SQL, note versions now store tags, unmount cleanup, stale log string, PATCH due_date validation.
**SSRF protection:** `services/llm.py` — new `_is_private_url(url)` helper uses `socket.getaddrinfo` + `ipaddress` to block requests to loopback, private, link-local, reserved, and multicast addresses before fetching. `follow_redirects` set to `False` (redirects to internal IPs would bypass the check). All web research and search_web tool calls go through `fetch_url_content`, so this covers the full pipeline.
**Config validation:** `config.py` — new `Config.validate()` classmethod checks `OLLAMA_NUM_CTX`, `LOG_RETENTION_DAYS`, `IMAGE_MAX_BYTES`, `SMTP_PORT` bounds, and `BASE_URL` format when OIDC is enabled. `app.py``Config.validate()` called at the top of `create_app()` so misconfigurations surface immediately with clear messages instead of cryptic runtime errors.
**create_buffer race:** `routes/chat.py` — wrapped `create_buffer(conv_id, assistant_msg.id)` in try/except RuntimeError → returns 409 instead of 500 if two concurrent requests race past the buffer-running check (which is possible because there are await points between the GET check and the create call).
**Stale log message:** `services/generation_task.py` — "90s" → "180s" in the model-load timeout warning.
**Tag autocomplete SQL:** `services/notes.py``get_all_tags()` now pushes the `q` filter into the SQL query (`ILIKE`) and adds `LIMIT 100` on both filtered and unfiltered paths, replacing the previous Python-side filter over a full table scan.
**Note versions now store tags:** Migration `0023_add_tags_to_note_versions.py` adds `tags TEXT[] NOT NULL DEFAULT '{}'` to `note_versions`. `models/note_version.py` — added `tags` field; `to_dict()` includes it. `services/note_versions.py``create_version()` accepts optional `tags` param. `services/notes.py` — snapshots `old_tags` before update, passes them to `create_version`. Frontend: `NoteVersion` interface (in `HistoryPanel.vue` and `types/task.ts`) updated with `tags: string[]`. `HistoryPanel.vue``restore` emit updated to include tags as second argument. `NoteEditorView.vue` — restore handler now restores both body and tags.
**useAssist unmount cleanup:** `NoteEditorView.vue` — added `onUnmounted(() => assist.clearSelection())` which closes any in-flight SSE stream handle, preventing orphaned server connections when navigating away mid-generate.
**PATCH due_date validation:** `routes/notes.py``date.fromisoformat()` in the PATCH handler now wrapped in try/except `(ValueError, TypeError)`, returning 400 with a clear message instead of a 500.
**Previous session (2026-03-06 earlier):** SSE reconnection for chat and writing assist.
**SSE reconnection — chat:** `stores/chat.ts` — new `reconnectIfGenerating(convId)` function exported from the store. Checks `isStreamingConv` first (skips if stream already active). Finds the last assistant message with `status: 'generating'`, removes it from the messages array (the `done` event re-adds the final version), initialises stream state with `status: "Reconnecting..."`, then calls `_streamGeneration` which replays all buffered events from `last_event_id=-1`. If the buffer is >60s stale (already cleaned up), `_streamGeneration` exhausts its retries and falls back to `fetchConversation` to show the DB-saved final content. `ChatView.vue``reconnectIfGenerating` called (fire-and-forget, no await) in both `onMounted` and `watch(convId)` after the fetch step, covering both the page-refresh and navigate-away-and-back scenarios.
**SSE reconnection — writing assistant:** `composables/useAssist.ts` — extracted `_buildProposedFullBody()` helper. `submit()` now wraps the SSE connection in a retry loop (up to 3 retries, exponential back-off 1 s / 2 s / 4 s, capped at 5 s). Tracks `lastEventId`; reconnects use `?last_event_id=N` so only new chunks are received (no duplication). Loop breaks early on `done`, `error`, or if state left `'streaming'` (user cancelled). Falls back to accumulated text as draft on exhausted retries.
**Model load timeout:** `generation_task.py``wait_for_model_loaded` timeout raised from 90 s to 180 s.
**Previous session (2026-03-05):** Note editor sidebar layout, full-document writing assist, persistent drafts, version history, context window bump.
**Note editor sidebar layout:** `NoteEditorView.vue` fully refactored into two-column layout matching `TaskEditorView`. Right sidebar (280px) contains Project, Milestone, Tags + tag suggestions, and the Writing Assistant panel (always visible, no toggle). Mobile collapses to accordion. Removed `assistOpen` ref and `✨ Assist` toggle button. `InlineAssistPanel` removed from note editor.