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:
@@ -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