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
+3 -2
View File
@@ -24,8 +24,9 @@ class Config:
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
# KV cache context window for generation. Lower = less VRAM, less throughput impact.
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "16384"))
# KV cache context window for generation. Higher = more RAM usage but longer inputs/outputs.
# 131072 is the practical maximum for most models. Lower this on RAM-constrained hosts.
OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "65536"))
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
+2
View File
@@ -30,3 +30,5 @@ from fabledassistant.models.push_subscription import PushSubscription # noqa: E
from fabledassistant.models.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
from fabledassistant.models.task_log import TaskLog # noqa: E402, F401
from fabledassistant.models.note_draft import NoteDraft # noqa: E402, F401
from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class NoteDraft(Base):
__tablename__ = "note_drafts"
id: Mapped[int] = mapped_column(primary_key=True)
note_id: Mapped[int] = mapped_column(Integer, ForeignKey("notes.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
proposed_body: Mapped[str] = mapped_column(Text)
original_body: Mapped[str] = mapped_column(Text)
instruction: Mapped[str] = mapped_column(Text, default="")
scope: Mapped[str] = mapped_column(Text, default="document")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"note_id": self.note_id,
"user_id": self.user_id,
"proposed_body": self.proposed_body,
"original_body": self.original_body,
"instruction": self.instruction,
"scope": self.scope,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -0,0 +1,31 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class NoteVersion(Base):
__tablename__ = "note_versions"
id: Mapped[int] = mapped_column(primary_key=True)
note_id: Mapped[int] = mapped_column(Integer, ForeignKey("notes.id", ondelete="CASCADE"))
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="")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
def to_dict(self, include_body: bool = True) -> dict:
d: dict = {
"id": self.id,
"note_id": self.note_id,
"user_id": self.user_id,
"title": self.title,
"created_at": self.created_at.isoformat(),
}
if include_body:
d["body"] = self.body
return d
+69 -3
View File
@@ -30,6 +30,8 @@ from fabledassistant.services.notes import (
)
from fabledassistant.services.settings import get_setting
from fabledassistant.services.tag_suggestions import suggest_tags
from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft
from fabledassistant.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__)
@@ -285,11 +287,15 @@ async def assist_route():
target_section = data.get("target_section", "")
instruction = data.get("instruction", "")
if not target_section or not instruction:
return jsonify({"error": "target_section and instruction are required"}), 400
whole_doc = bool(data.get("whole_doc", False))
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
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
messages = build_assist_messages(body, target_section, instruction)
messages = build_assist_messages(body, target_section, instruction, whole_doc=whole_doc)
buf = create_assist_buffer(uid)
asyncio.create_task(run_assist_generation(buf, messages, model))
@@ -336,3 +342,63 @@ async def assist_stream_route():
"X-Accel-Buffering": "no",
},
)
# ── Draft routes ─────────────────────────────────────────────────────────────
@notes_bp.route("/<int:note_id>/draft", methods=["GET"])
@login_required
async def get_draft_route(note_id: int):
uid = get_current_user_id()
draft = await get_draft(uid, note_id)
if draft is None:
return jsonify({"error": "No draft found"}), 404
return jsonify(draft.to_dict())
@notes_bp.route("/<int:note_id>/draft", methods=["PUT"])
@login_required
async def upsert_draft_route(note_id: int):
uid = get_current_user_id()
# Verify note ownership
note = await get_note(uid, note_id)
if note is None:
return jsonify({"error": "Note not found"}), 404
data = await request.get_json()
draft = await upsert_draft(
user_id=uid,
note_id=note_id,
proposed_body=data.get("proposed_body", ""),
original_body=data.get("original_body", ""),
instruction=data.get("instruction", ""),
scope=data.get("scope", "document"),
)
return jsonify(draft.to_dict()), 200
@notes_bp.route("/<int:note_id>/draft", methods=["DELETE"])
@login_required
async def delete_draft_route(note_id: int):
uid = get_current_user_id()
await delete_draft(uid, note_id)
return "", 204
# ── Version routes ────────────────────────────────────────────────────────────
@notes_bp.route("/<int:note_id>/versions", methods=["GET"])
@login_required
async def list_versions_route(note_id: int):
uid = get_current_user_id()
versions = await list_versions(uid, note_id)
return jsonify({"versions": [v.to_dict(include_body=False) for v in versions]})
@notes_bp.route("/<int:note_id>/versions/<int:version_id>", methods=["GET"])
@login_required
async def get_version_route(note_id: int, version_id: int):
uid = get_current_user_id()
version = await get_version(uid, note_id, version_id)
if version is None:
return jsonify({"error": "Version not found"}), 404
return jsonify(version.to_dict(include_body=True))
+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: