Add multi-user auth, background generation, and chat UX improvements
Phase 5: Multi-user authentication with session cookies, bcrypt passwords, first-user-is-admin pattern, per-user data isolation, backup/restore, Docker Swarm production stack with secrets and network isolation. Phase 5.1: Chat UX improvements: - Background generation architecture (GenerationBuffer + asyncio task) with SSE fan-out, reconnect support, and periodic DB flushes - LLM-generated conversation titles (first exchange + every 10th message) - Stop generation button with cancel_event and partial content preservation - Relative timestamps in sidebar (5m ago, 3h ago, then dates) - Empty chat auto-cleanup on navigation away - Save-as-note uses LLM for title generation, tags notes with "chat" - Summarize-as-note also tags with "chat" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_note(
|
||||
user_id: int,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
tags: list[str] | None = None,
|
||||
@@ -20,6 +21,7 @@ async def create_note(
|
||||
) -> Note:
|
||||
async with async_session() as session:
|
||||
note = Note(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
body=body,
|
||||
tags=tags or [],
|
||||
@@ -34,12 +36,16 @@ async def create_note(
|
||||
return note
|
||||
|
||||
|
||||
async def get_note(note_id: int) -> Note | None:
|
||||
async def get_note(user_id: int, note_id: int) -> Note | None:
|
||||
async with async_session() as session:
|
||||
return await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def list_notes(
|
||||
user_id: int,
|
||||
q: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
is_task: bool | None = None,
|
||||
@@ -51,8 +57,8 @@ async def list_notes(
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Note], int]:
|
||||
async with async_session() as session:
|
||||
query = select(Note)
|
||||
count_query = select(func.count(Note.id))
|
||||
query = select(Note).where(Note.user_id == user_id)
|
||||
count_query = select(func.count(Note.id)).where(Note.user_id == user_id)
|
||||
|
||||
# Filter by task vs note
|
||||
if is_task is True:
|
||||
@@ -104,25 +110,31 @@ async def list_notes(
|
||||
return notes, total
|
||||
|
||||
|
||||
async def get_note_by_title(title: str) -> Note | None:
|
||||
async def get_note_by_title(user_id: int, title: str) -> Note | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Note).where(func.lower(Note.title) == func.lower(title.strip())).limit(1)
|
||||
select(Note).where(
|
||||
Note.user_id == user_id,
|
||||
func.lower(Note.title) == func.lower(title.strip()),
|
||||
).limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_or_create_note_by_title(title: str) -> Note:
|
||||
async def get_or_create_note_by_title(user_id: int, title: str) -> Note:
|
||||
title = title.strip()
|
||||
note = await get_note_by_title(title)
|
||||
note = await get_note_by_title(user_id, title)
|
||||
if note:
|
||||
return note
|
||||
return await create_note(title=title)
|
||||
return await create_note(user_id, title=title)
|
||||
|
||||
|
||||
async def update_note(note_id: int, **fields: object) -> Note | None:
|
||||
async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
@@ -139,9 +151,12 @@ async def update_note(note_id: int, **fields: object) -> Note | None:
|
||||
return note
|
||||
|
||||
|
||||
async def delete_note(note_id: int) -> bool:
|
||||
async def delete_note(user_id: int, note_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
return False
|
||||
await session.delete(note)
|
||||
@@ -149,10 +164,13 @@ async def delete_note(note_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def get_all_tags(q: str | None = None) -> list[str]:
|
||||
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT DISTINCT unnest(tags) AS tag FROM notes WHERE tags != '{}'")
|
||||
text(
|
||||
"SELECT DISTINCT unnest(tags) AS tag FROM notes"
|
||||
" WHERE tags != '{}' AND user_id = :user_id"
|
||||
).bindparams(user_id=user_id)
|
||||
)
|
||||
all_tags = [row[0] for row in result.fetchall()]
|
||||
|
||||
@@ -163,9 +181,12 @@ async def get_all_tags(q: str | None = None) -> list[str]:
|
||||
return sorted(all_tags)
|
||||
|
||||
|
||||
async def convert_note_to_task(note_id: int) -> Note:
|
||||
async def convert_note_to_task(user_id: int, note_id: int) -> Note:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
logger.warning("convert_note_to_task: note %d not found", note_id)
|
||||
raise ValueError("Note not found")
|
||||
@@ -178,9 +199,12 @@ async def convert_note_to_task(note_id: int) -> Note:
|
||||
return note
|
||||
|
||||
|
||||
async def convert_task_to_note(note_id: int) -> Note:
|
||||
async def convert_task_to_note(user_id: int, note_id: int) -> Note:
|
||||
async with async_session() as session:
|
||||
note = await session.get(Note, note_id)
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||
)
|
||||
note = result.scalars().first()
|
||||
if note is None:
|
||||
logger.warning("convert_task_to_note: note %d not found", note_id)
|
||||
raise ValueError("Note not found")
|
||||
@@ -195,6 +219,7 @@ async def convert_task_to_note(note_id: int) -> Note:
|
||||
|
||||
|
||||
async def search_notes_for_context(
|
||||
user_id: int,
|
||||
keywords: list[str],
|
||||
exclude_ids: set[int] | None = None,
|
||||
limit: int = 3,
|
||||
@@ -207,7 +232,7 @@ async def search_notes_for_context(
|
||||
pattern = f"%{escaped}%"
|
||||
keyword_filters.append(or_(Note.title.ilike(pattern), Note.body.ilike(pattern)))
|
||||
|
||||
query = select(Note).where(or_(*keyword_filters))
|
||||
query = select(Note).where(Note.user_id == user_id, or_(*keyword_filters))
|
||||
if exclude_ids:
|
||||
query = query.where(Note.id.notin_(exclude_ids))
|
||||
query = query.order_by(Note.updated_at.desc()).limit(limit)
|
||||
@@ -216,8 +241,8 @@ async def search_notes_for_context(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_backlinks(note_id: int) -> list[dict]:
|
||||
note = await get_note(note_id)
|
||||
async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
|
||||
note = await get_note(user_id, note_id)
|
||||
if note is None:
|
||||
return []
|
||||
|
||||
@@ -232,6 +257,7 @@ async def get_backlinks(note_id: int) -> list[dict]:
|
||||
|
||||
results = await session.execute(
|
||||
select(Note.id, Note.title, Note.status).where(
|
||||
Note.user_id == user_id,
|
||||
Note.id != note_id,
|
||||
or_(Note.body.like(pattern), Note.body.like(pattern_alias)),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user