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 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 17:10:55 -05:00
parent b11a92f32d
commit 9036dfd931
17 changed files with 1275 additions and 278 deletions
+38 -25
View File
@@ -2,38 +2,51 @@ MAX_BODY_CHARS = 8000
def build_assist_messages(
body: str, target_section: str, instruction: str
body: str, target_section: str, instruction: str, whole_doc: bool = False
) -> list[dict]:
"""Build Ollama messages for section-level assist.
"""Build Ollama messages for writing assist.
The full note body (truncated) is provided as read-only context in the
system prompt. The target section + user instruction go in the user
message. The model outputs only the replacement for the target section.
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.
"""
truncated_body = body[:MAX_BODY_CHARS]
if len(body) > MAX_BODY_CHARS:
truncated_body += "\n... (truncated)"
system_content = (
"You are an AI writing assistant integrated into a note-taking app. "
"The user is editing a document. The full document is shown below for context.\n\n"
f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n"
"The user will give you a specific section of the document and an instruction. "
"Output ONLY the replacement text for that section. "
"If the target section starts with a markdown heading (e.g. ## Heading), "
"your output MUST also start with a heading at the same level. "
"You may revise the heading text but do not remove it. "
"Do not include other sections, explanatory text, or markdown code fences around the output. "
"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."
)
user_content = (
f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n"
f"Instruction: {instruction}"
)
if whole_doc:
system_content = (
"You are a writing assistant. Revise the document per the instruction. "
"Output ONLY the complete revised document. Preserve all structure and "
"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."
)
user_content = (
f"--- Document ---\n{truncated_body}\n--- End Document ---\n\n"
f"Instruction: {instruction}"
)
else:
system_content = (
"You are an AI writing assistant integrated into a note-taking app. "
"The user is editing a document. The full document is shown below for context.\n\n"
f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n"
"The user will give you a specific section of the document and an instruction. "
"Output ONLY the replacement text for that section. "
"If the target section starts with a markdown heading (e.g. ## Heading), "
"your output MUST also start with a heading at the same level. "
"You may revise the heading text but do not remove it. "
"Do not include other sections, explanatory text, or markdown code fences around the output. "
"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."
)
user_content = (
f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n"
f"Instruction: {instruction}"
)
return [
{"role": "system", "content": system_content},
@@ -425,7 +425,7 @@ async def run_assist_generation(
await asyncio.sleep(delay)
try:
buf.content_so_far = ""
async for chunk in stream_chat(messages, model, options={"num_predict": 4096}):
async for chunk in stream_chat(messages, model, options={"num_predict": Config.OLLAMA_NUM_CTX}):
buf.content_so_far += chunk
buf.append_event("chunk", {"chunk": chunk})
@@ -0,0 +1,66 @@
from sqlalchemy import select, text
from fabledassistant.models import async_session
from fabledassistant.models.note_draft import NoteDraft
async def upsert_draft(
user_id: int,
note_id: int,
proposed_body: str,
original_body: str,
instruction: str,
scope: str,
) -> NoteDraft:
async with async_session() as session:
await session.execute(
text("""
INSERT INTO note_drafts (note_id, user_id, proposed_body, original_body, instruction, scope)
VALUES (:note_id, :user_id, :proposed_body, :original_body, :instruction, :scope)
ON CONFLICT (note_id, user_id) DO UPDATE SET
proposed_body = EXCLUDED.proposed_body,
original_body = EXCLUDED.original_body,
instruction = EXCLUDED.instruction,
scope = EXCLUDED.scope,
updated_at = NOW()
""").bindparams(
note_id=note_id,
user_id=user_id,
proposed_body=proposed_body,
original_body=original_body,
instruction=instruction,
scope=scope,
)
)
await session.commit()
result = await session.execute(
select(NoteDraft).where(
NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
)
)
return result.scalars().first()
async def get_draft(user_id: int, note_id: int) -> NoteDraft | None:
async with async_session() as session:
result = await session.execute(
select(NoteDraft).where(
NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
)
)
return result.scalars().first()
async def delete_draft(user_id: int, note_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(NoteDraft).where(
NoteDraft.note_id == note_id, NoteDraft.user_id == user_id
)
)
draft = result.scalars().first()
if draft is None:
return False
await session.delete(draft)
await session.commit()
return True
@@ -0,0 +1,51 @@
from sqlalchemy import select, text
from fabledassistant.models import async_session
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 with async_session() as session:
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title)
session.add(version)
await session.commit()
await session.refresh(version)
# Prune versions beyond MAX_VERSIONS
await session.execute(
text("""
DELETE FROM note_versions
WHERE id IN (
SELECT id FROM note_versions
WHERE note_id = :note_id AND user_id = :user_id
ORDER BY created_at DESC
OFFSET :max_versions
)
""").bindparams(note_id=note_id, user_id=user_id, max_versions=MAX_VERSIONS)
)
await session.commit()
return version
async def list_versions(user_id: int, note_id: int) -> list[NoteVersion]:
async with async_session() as session:
result = await session.execute(
select(NoteVersion)
.where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
.order_by(NoteVersion.created_at.desc())
)
return list(result.scalars().all())
async def get_version(user_id: int, note_id: int, version_id: int) -> NoteVersion | None:
async with async_session() as session:
result = await session.execute(
select(NoteVersion).where(
NoteVersion.id == version_id,
NoteVersion.note_id == note_id,
NoteVersion.user_id == user_id,
)
)
return result.scalars().first()
+10 -1
View File
@@ -194,6 +194,9 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
note = result.scalars().first()
if note is None:
return None
# Snapshot before changes for version creation
old_body = note.body
old_title = note.title
for key, value in fields.items():
if not hasattr(note, key):
continue
@@ -207,7 +210,13 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
note.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(note)
return note
# 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)
return note
async def delete_note(user_id: int, note_id: int) -> bool: