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:
@@ -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")
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface NoteVersion {
|
||||
id: number;
|
||||
note_id: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
body?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -12,7 +12,53 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-03-05 — Note editor sidebar layout, full-document writing assist, persistent drafts, version history, context window bump.
|
||||
2026-03-06 — Project-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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user