From 9036dfd93155c1151a32a9ede244afe299f66ddc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 5 Mar 2026 17:10:55 -0500 Subject: [PATCH] Note editor sidebar, full-doc assist, persistent drafts, version history NoteEditorView: two-column sidebar layout (project/milestone/tags/assist always visible), removed assist toggle button, InlineAssistPanel removed. Writing assist: whole_doc mode rewrites entire document; DiffView.vue replaces editor during review showing full-document diff. Scope dropdown in sidebar switches between whole-document and section modes. Persistent drafts: migration 0022 adds note_drafts (UNIQUE per note+user) and note_versions (max 20, auto-pruned) tables. Draft saved after generation completes, restored on editor mount, cleared on accept/reject. Version snapshot created automatically whenever note body changes on save. HistoryPanel.vue: version list + DiffView modal, restore button writes body back to editor. Config: OLLAMA_NUM_CTX default raised to 65536; assist num_predict now tracks Config.OLLAMA_NUM_CTX instead of a hardcoded 4096. Co-Authored-By: Claude Sonnet 4.6 --- .../0022_add_note_versions_and_drafts.py | 43 ++ frontend/src/components/DiffView.vue | 118 ++++ frontend/src/components/HistoryPanel.vue | 271 +++++++++ frontend/src/composables/useAssist.ts | 168 +++-- frontend/src/types/task.ts | 19 + frontend/src/views/NoteEditorView.vue | 572 ++++++++++++------ src/fabledassistant/config.py | 5 +- src/fabledassistant/models/__init__.py | 2 + src/fabledassistant/models/note_draft.py | 39 ++ src/fabledassistant/models/note_version.py | 31 + src/fabledassistant/routes/notes.py | 72 ++- src/fabledassistant/services/assist.py | 63 +- .../services/generation_task.py | 2 +- src/fabledassistant/services/note_drafts.py | 66 ++ src/fabledassistant/services/note_versions.py | 51 ++ src/fabledassistant/services/notes.py | 11 +- summary.md | 20 +- 17 files changed, 1275 insertions(+), 278 deletions(-) create mode 100644 alembic/versions/0022_add_note_versions_and_drafts.py create mode 100644 frontend/src/components/DiffView.vue create mode 100644 frontend/src/components/HistoryPanel.vue create mode 100644 src/fabledassistant/models/note_draft.py create mode 100644 src/fabledassistant/models/note_version.py create mode 100644 src/fabledassistant/services/note_drafts.py create mode 100644 src/fabledassistant/services/note_versions.py diff --git a/alembic/versions/0022_add_note_versions_and_drafts.py b/alembic/versions/0022_add_note_versions_and_drafts.py new file mode 100644 index 0000000..8ff1a61 --- /dev/null +++ b/alembic/versions/0022_add_note_versions_and_drafts.py @@ -0,0 +1,43 @@ +"""Add note_drafts and note_versions tables.""" + +from alembic import op + +revision = "0022" +down_revision = "0021" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE IF NOT EXISTS note_drafts ( + id SERIAL PRIMARY KEY, + note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + proposed_body TEXT NOT NULL, + original_body TEXT NOT NULL, + instruction TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'document', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (note_id, user_id) + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_note_drafts_note_id ON note_drafts(note_id)") + + op.execute(""" + CREATE TABLE IF NOT EXISTS note_versions ( + id SERIAL PRIMARY KEY, + note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX IF NOT EXISTS ix_note_versions_note_id ON note_versions(note_id)") + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_note_versions_note_id") + op.execute("DROP TABLE IF EXISTS note_versions") + op.execute("DROP INDEX IF EXISTS ix_note_drafts_note_id") + op.execute("DROP TABLE IF EXISTS note_drafts") diff --git a/frontend/src/components/DiffView.vue b/frontend/src/components/DiffView.vue new file mode 100644 index 0000000..c6f5329 --- /dev/null +++ b/frontend/src/components/DiffView.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue new file mode 100644 index 0000000..20df7bf --- /dev/null +++ b/frontend/src/components/HistoryPanel.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/frontend/src/composables/useAssist.ts b/frontend/src/composables/useAssist.ts index cd77b87..ded6b39 100644 --- a/frontend/src/composables/useAssist.ts +++ b/frontend/src/composables/useAssist.ts @@ -1,5 +1,5 @@ import { ref, computed, watch, type Ref } from "vue"; -import { apiPost, apiSSEStream, type SSEStreamHandle } from "@/api/client"; +import { apiPost, apiPut, apiDelete, apiSSEStream, type SSEStreamHandle } from "@/api/client"; import { useToastStore } from "@/stores/toast"; import { parseMarkdownSections, @@ -7,6 +7,7 @@ import { } from "@/utils/sectionParser"; export type AssistState = "idle" | "streaming" | "review"; +export type ScopeMode = "document" | "section"; export interface AssistTarget { text: string; @@ -19,6 +20,17 @@ export interface DiffLine { text: string; } +export interface NoteDraft { + id: number; + note_id: number; + proposed_body: string; + original_body: string; + instruction: string; + scope: string; + created_at: string; + updated_at: string; +} + function computeDiff(a: string, b: string): DiffLine[] { const aLines = a.split('\n'); const bLines = b.split('\n'); @@ -41,24 +53,28 @@ function computeDiff(a: string, b: string): DiffLine[] { return result; } -export function useAssist(body: Ref) { +export function useAssist(body: Ref, noteId?: Ref) { const toast = useToastStore(); const state = ref("idle"); + const scopeMode = ref("document"); const sections = ref([]); const selectedSection = ref(null); const customSelection = ref<{ start: number; end: number; text: string } | null>(null); const instruction = ref(""); const streamingText = ref(""); const proposedText = ref(""); + // Full proposed document body (for section mode: body with section replaced) + const proposedFullBody = ref(""); const error = ref(""); const isProofreading = ref(false); - // Snapshot of body at the time a section/selection was chosen + // Snapshot of body at the time generation was started let bodySnapshot = ""; let streamHandle: SSEStreamHandle | null = null; const target = computed(() => { + if (scopeMode.value === 'document') return null; if (customSelection.value) { return { text: customSelection.value.text, @@ -76,16 +92,16 @@ export function useAssist(body: Ref) { return null; }); - const canSubmit = computed( - () => - target.value !== null && - instruction.value.trim().length > 0 && - state.value !== "streaming" + const canSubmit = computed(() => + instruction.value.trim().length > 0 && + state.value !== "streaming" && + (scopeMode.value === 'document' || target.value !== null) ); + // Diff always compares full document: original snapshot → proposed const diff = computed(() => { - if (state.value !== 'review' || !target.value || !proposedText.value) return []; - return computeDiff(target.value.text, proposedText.value); + if (state.value !== 'review' || !proposedFullBody.value) return []; + return computeDiff(bodySnapshot, proposedFullBody.value); }); function refreshSections() { @@ -93,21 +109,21 @@ export function useAssist(body: Ref) { } function selectSection(section: MarkdownSection) { + scopeMode.value = 'section'; customSelection.value = null; selectedSection.value = section; - bodySnapshot = body.value; error.value = ""; } function selectTextRange(start: number, end: number) { if (start === end) return; + scopeMode.value = 'section'; selectedSection.value = null; customSelection.value = { start, end, text: body.value.slice(start, end), }; - bodySnapshot = body.value; error.value = ""; } @@ -121,35 +137,84 @@ export function useAssist(body: Ref) { instruction.value = ""; streamingText.value = ""; proposedText.value = ""; + proposedFullBody.value = ""; error.value = ""; state.value = "idle"; + isProofreading.value = false; + } + + async function _saveDraft() { + if (!noteId?.value) return; + try { + await apiPut(`/api/notes/${noteId.value}/draft`, { + proposed_body: proposedFullBody.value, + original_body: bodySnapshot, + instruction: instruction.value, + scope: scopeMode.value, + }); + } catch { + // Non-critical — ignore draft save errors + } + } + + async function _deleteDraft() { + if (!noteId?.value) return; + try { + await apiDelete(`/api/notes/${noteId.value}/draft`); + } catch { + // Ignore — draft may not exist + } } async function submit() { - if (!canSubmit.value || !target.value) return; + if (!canSubmit.value) return; + bodySnapshot = body.value; state.value = "streaming"; streamingText.value = ""; proposedText.value = ""; + proposedFullBody.value = ""; error.value = ""; + const wholeDoc = scopeMode.value === 'document'; + const targetSection = wholeDoc ? body.value : (target.value?.text ?? ""); + try { - // Step 1: Launch background generation await apiPost("/api/notes/assist", { body: body.value, - target_section: target.value.text, + target_section: targetSection, instruction: instruction.value, + whole_doc: wholeDoc, }); - // Step 2: Tail the SSE buffer - streamHandle = apiSSEStream("/api/notes/assist/stream", (evt) => { + streamHandle = apiSSEStream("/api/notes/assist/stream", async (evt) => { if (evt.event === "chunk") { streamingText.value += evt.data.chunk as string; } if (evt.event === "done") { - proposedText.value = (evt.data.full_text as string) || streamingText.value; + const fullText = (evt.data.full_text as string) || streamingText.value; + proposedText.value = fullText; + + 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; + } + } + state.value = "review"; streamHandle = null; + await _saveDraft(); } if (evt.event === "error") { error.value = evt.data.error as string; @@ -161,11 +226,26 @@ export function useAssist(body: Ref) { await streamHandle.done; - // Fallback: if stream ended without done/error, use accumulated text 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; + } + } state.value = "review"; + await _saveDraft(); } else { state.value = "idle"; } @@ -182,9 +262,9 @@ export function useAssist(body: Ref) { async function proofread() { if (state.value === 'streaming') return; isProofreading.value = true; + scopeMode.value = 'document'; selectedSection.value = null; - customSelection.value = { start: 0, end: body.value.length, text: body.value }; - bodySnapshot = body.value; + customSelection.value = null; error.value = ''; instruction.value = "Proofread for clarity, grammar, spelling, and flow. " + @@ -194,60 +274,48 @@ export function useAssist(body: Ref) { } function accept(): string { - if (!target.value || !proposedText.value) return body.value; + const result = proposedFullBody.value || body.value; - const t = target.value; - - // Validate that the body hasn't changed at the target offsets - if (body.value !== bodySnapshot) { - const currentSlice = body.value.slice(t.startOffset, t.endOffset); - if (currentSlice !== t.text) { - error.value = "The document changed since this suggestion was made. Please clear and try again."; - toast.show(error.value, "error"); - state.value = "idle"; - return body.value; - } - } - - // Ensure proposed text ends with a newline for clean separation - let text = proposedText.value; - if (!text.endsWith("\n")) { - text += "\n"; - } - - const newBody = - body.value.slice(0, t.startOffset) + - text + - body.value.slice(t.endOffset); - - // Reset state selectedSection.value = null; customSelection.value = null; streamingText.value = ""; proposedText.value = ""; + proposedFullBody.value = ""; error.value = ""; state.value = "idle"; isProofreading.value = false; - return newBody; + _deleteDraft(); + return result; } function reject() { proposedText.value = ""; + proposedFullBody.value = ""; streamingText.value = ""; error.value = ""; state.value = "idle"; isProofreading.value = false; + _deleteDraft(); // Keep selection + instruction for retry } - // Keep sections in sync with body automatically + function loadDraft(draft: NoteDraft) { + bodySnapshot = draft.original_body; + proposedText.value = draft.proposed_body; + proposedFullBody.value = draft.proposed_body; + instruction.value = draft.instruction; + scopeMode.value = draft.scope as ScopeMode; + state.value = "review"; + } + watch(body, () => { refreshSections(); }, { immediate: true }); return { state, + scopeMode, sections, selectedSection, customSelection, @@ -255,6 +323,7 @@ export function useAssist(body: Ref) { instruction, streamingText, proposedText, + proposedFullBody, error, canSubmit, diff, @@ -267,5 +336,6 @@ export function useAssist(body: Ref) { proofread, accept, reject, + loadDraft, }; } diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index f4899b7..b147c41 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -13,3 +13,22 @@ export interface TaskLog { created_at: string; updated_at: string; } + +export interface NoteDraft { + id: number; + note_id: number; + proposed_body: string; + original_body: string; + instruction: string; + scope: string; + created_at: string; + updated_at: string; +} + +export interface NoteVersion { + id: number; + note_id: number; + title: string; + body?: string; + created_at: string; +} diff --git a/frontend/src/views/NoteEditorView.vue b/frontend/src/views/NoteEditorView.vue index 052eaf9..393fda6 100644 --- a/frontend/src/views/NoteEditorView.vue +++ b/frontend/src/views/NoteEditorView.vue @@ -4,15 +4,16 @@ import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router"; import { useNotesStore } from "@/stores/notes"; import { useToastStore } from "@/stores/toast"; import { renderMarkdown } from "@/utils/markdown"; -import { useAssist } from "@/composables/useAssist"; -import { apiPost } from "@/api/client"; +import { useAssist, type NoteDraft } from "@/composables/useAssist"; +import { apiPost, apiGet } from "@/api/client"; import type { Editor } from "@tiptap/vue-3"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import TiptapEditor from "@/components/TiptapEditor.vue"; import TagInput from "@/components/TagInput.vue"; import ProjectSelector from "@/components/ProjectSelector.vue"; import MilestoneSelector from "@/components/MilestoneSelector.vue"; -import InlineAssistPanel from "@/components/InlineAssistPanel.vue"; +import DiffView from "@/components/DiffView.vue"; +import HistoryPanel from "@/components/HistoryPanel.vue"; const route = useRoute(); const router = useRouter(); @@ -27,6 +28,8 @@ const milestoneId = ref(null); const dirty = ref(false); const saving = ref(false); const showPreview = ref(false); +const sidebarOpen = ref(true); +const showHistory = ref(false); const editorRef = ref | null>(null); const tiptapEditor = computed(() => { return (editorRef.value?.editor as Editor | undefined) ?? null; @@ -39,24 +42,46 @@ const isEditing = computed(() => noteId.value !== null); const renderedPreview = computed(() => renderMarkdown(body.value)); -// AI Assist -const assist = useAssist(body); +// AI Assist — pass noteId for draft persistence +const assist = useAssist(body, noteId); -const assistLabel = computed(() => { - if (assist.isProofreading.value) return "Proofreading document..."; - const t = assist.target.value; - if (!t) return "Generating..."; - const heading = t.text.split("\n")[0]; - return `Revising: "${heading.length > 50 ? heading.slice(0, 50) + "..." : heading}"`; +// Scope selector options: "Whole document" + one per section +const scopeOptions = computed(() => { + const opts: Array<{ value: string; label: string }> = [ + { value: '__document__', label: 'Whole document' }, + ]; + for (const section of assist.sections.value) { + opts.push({ + value: String(assist.sections.value.indexOf(section)), + label: section.heading || '(preamble)', + }); + } + return opts; }); -// Assist panel toggle (persisted) -const ASSIST_KEY = 'fa-assist-open'; -const assistOpen = ref(localStorage.getItem(ASSIST_KEY) !== 'false'); -function toggleAssist() { - assistOpen.value = !assistOpen.value; - localStorage.setItem(ASSIST_KEY, String(assistOpen.value)); -} +const scopeSelectValue = computed({ + get() { + if (assist.scopeMode.value === 'document') return '__document__'; + if (assist.selectedSection.value) { + const idx = assist.sections.value.indexOf(assist.selectedSection.value); + return idx >= 0 ? String(idx) : '__document__'; + } + return '__document__'; + }, + set(val: string) { + if (val === '__document__') { + assist.scopeMode.value = 'document'; + assist.selectedSection.value = null; + } else { + const idx = Number(val); + const section = assist.sections.value[idx]; + if (section) { + assist.scopeMode.value = 'section'; + assist.selectSection(section); + } + } + }, +}); // Floating inline assist button const floatingAssist = ref({ show: false, top: 0, left: 0 }); @@ -79,25 +104,17 @@ function onSelectionChange(payload: { text: string; start: number; end: number } function handleInlineAssist() { if (!pendingSelection.value) return; + assist.scopeMode.value = 'section'; assist.selectTextRange(pendingSelection.value.start, pendingSelection.value.end); - assistOpen.value = true; - localStorage.setItem(ASSIST_KEY, 'true'); floatingAssist.value.show = false; nextTick(() => instructionRef.value?.focus()); } function handleAssistAccept() { const newBody = assist.accept(); - if (newBody !== body.value) { - body.value = newBody; - markDirty(); - toast.show("Section updated"); - } -} - -function truncateTarget(text: string, max = 60): string { - const first = text.split("\n")[0]; - return first.length > max ? first.slice(0, max) + "..." : first; + body.value = newBody; + markDirty(); + toast.show("Document updated"); } // Tag suggestions @@ -174,6 +191,14 @@ onMounted(async () => { savedProjectId = projectId.value; savedMilestoneId = milestoneId.value; } + + // Restore pending draft if any + try { + const draft = await apiGet(`/api/notes/${noteId.value}/draft`); + if (draft) assist.loadDraft(draft); + } catch { + // No draft — normal + } } }); @@ -218,9 +243,7 @@ async function save() { const showDeleteConfirm = ref(false); function remove() { - if (noteId.value) { - showDeleteConfirm.value = true; - } + if (noteId.value) showDeleteConfirm.value = true; } async function confirmDelete() { @@ -236,14 +259,17 @@ async function confirmDelete() { } } -// Auto-save every 5 minutes when editing an existing note +// Auto-save every 5 minutes let autoSaveTimer: ReturnType | null = null; async function autoSave() { if (!isEditing.value || !dirty.value || saving.value) return; saving.value = true; try { - await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value, project_id: projectId.value, milestone_id: milestoneId.value } as Record); + await store.updateNote(noteId.value!, { + title: title.value, body: body.value, tags: tags.value, + project_id: projectId.value, milestone_id: milestoneId.value, + } as Record); savedTitle = title.value; savedBody = body.value; savedTags = [...tags.value]; @@ -252,7 +278,7 @@ async function autoSave() { dirty.value = false; toast.show("Auto-saved"); } catch { - // Silent — user can still save manually + // Silent } finally { saving.value = false; } @@ -261,7 +287,6 @@ async function autoSave() { onMounted(() => { autoSaveTimer = setInterval(autoSave, 5 * 60 * 1000); }); onUnmounted(() => { if (autoSaveTimer !== null) clearInterval(autoSaveTimer); }); -// Ctrl+S handler function onKeydown(e: KeyboardEvent) { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); @@ -272,37 +297,27 @@ function onKeydown(e: KeyboardEvent) { onMounted(() => document.addEventListener("keydown", onKeydown)); onUnmounted(() => document.removeEventListener("keydown", onKeydown)); -// Unsaved changes guard — in-app navigation onBeforeRouteLeave(() => { - if (dirty.value) { - return confirm("You have unsaved changes. Leave anyway?"); - } + if (dirty.value) return confirm("You have unsaved changes. Leave anyway?"); }); -// Unsaved changes guard — browser close/reload function onBeforeUnload(e: BeforeUnloadEvent) { - if (dirty.value) { - e.preventDefault(); - } + if (dirty.value) e.preventDefault(); } onMounted(() => window.addEventListener("beforeunload", onBeforeUnload)); onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));