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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user