586026bff6
Replace plain textarea with Tiptap (ProseMirror) WYSIWYG editor for notes and tasks. Headings, bold, lists, and code blocks now render inline while editing. Tags and wikilinks get visual highlighting via decoration plugins, and autocomplete uses @tiptap/suggestion with heading disambiguation. Layout: pin navbar with app-shell flex column (100dvh), sticky editor toolbar above scrolling content, AI Assist panel fixed at bottom 1/3 with full-height section selector and prompt. Increase max-width to 960px across all views. Hide assist controls during streaming/review. Other fixes: markdown paste handling, auto-syncing assist sections via body watcher, trailing newline on AI accept, ChatView height fix. Add README.md describing the app, usage guide, and technical overview. Update summary.md with Phase 5.2 roadmap and current status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
235 lines
7.6 KiB
Python
235 lines
7.6 KiB
Python
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.conversation import Conversation, Message
|
|
from fabledassistant.config import Config
|
|
from fabledassistant.services.llm import generate_completion
|
|
from fabledassistant.services.notes import create_note
|
|
from fabledassistant.services.settings import get_setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def create_conversation(
|
|
user_id: int, title: str = "", model: str = ""
|
|
) -> Conversation:
|
|
async with async_session() as session:
|
|
conv = Conversation(user_id=user_id, title=title, model=model)
|
|
session.add(conv)
|
|
await session.commit()
|
|
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
|
|
result = await session.execute(
|
|
select(Conversation)
|
|
.options(selectinload(Conversation.messages))
|
|
.where(Conversation.id == conv.id)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_conversation(
|
|
user_id: int, conversation_id: int
|
|
) -> Conversation | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Conversation)
|
|
.options(selectinload(Conversation.messages))
|
|
.where(
|
|
Conversation.id == conversation_id,
|
|
Conversation.user_id == user_id,
|
|
)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def list_conversations(
|
|
user_id: int, limit: int = 50, offset: int = 0
|
|
) -> tuple[list[dict], int]:
|
|
async with async_session() as session:
|
|
total = await session.scalar(
|
|
select(func.count(Conversation.id)).where(
|
|
Conversation.user_id == user_id
|
|
)
|
|
) or 0
|
|
|
|
# Subquery for message count — avoids loading all messages
|
|
msg_count = (
|
|
select(func.count(Message.id))
|
|
.where(Message.conversation_id == Conversation.id)
|
|
.correlate(Conversation)
|
|
.scalar_subquery()
|
|
)
|
|
|
|
result = await session.execute(
|
|
select(Conversation, msg_count.label("message_count"))
|
|
.where(Conversation.user_id == user_id)
|
|
.order_by(Conversation.updated_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
|
|
conversations = []
|
|
for row in result.all():
|
|
conv = row[0]
|
|
d = {
|
|
"id": conv.id,
|
|
"title": conv.title,
|
|
"model": conv.model,
|
|
"message_count": row[1],
|
|
"created_at": conv.created_at.isoformat(),
|
|
"updated_at": conv.updated_at.isoformat(),
|
|
}
|
|
conversations.append(d)
|
|
|
|
return conversations, total
|
|
|
|
|
|
async def delete_conversation(user_id: int, conversation_id: int) -> bool:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Conversation).where(
|
|
Conversation.id == conversation_id,
|
|
Conversation.user_id == user_id,
|
|
)
|
|
)
|
|
conv = result.scalars().first()
|
|
if conv is None:
|
|
return False
|
|
await session.delete(conv)
|
|
await session.commit()
|
|
return True
|
|
|
|
|
|
async def update_conversation_title(
|
|
user_id: int, conversation_id: int, title: str
|
|
) -> Conversation | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Conversation).where(
|
|
Conversation.id == conversation_id,
|
|
Conversation.user_id == user_id,
|
|
)
|
|
)
|
|
conv = result.scalars().first()
|
|
if conv is None:
|
|
return None
|
|
conv.title = title
|
|
conv.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(conv)
|
|
return conv
|
|
|
|
|
|
async def add_message(
|
|
conversation_id: int,
|
|
role: str,
|
|
content: str,
|
|
context_note_id: int | None = None,
|
|
status: str | None = None,
|
|
) -> Message:
|
|
async with async_session() as session:
|
|
kwargs: dict = dict(
|
|
conversation_id=conversation_id,
|
|
role=role,
|
|
content=content,
|
|
context_note_id=context_note_id,
|
|
)
|
|
if status is not None:
|
|
kwargs["status"] = status
|
|
msg = Message(**kwargs)
|
|
session.add(msg)
|
|
# Touch conversation updated_at
|
|
conv = await session.get(Conversation, conversation_id)
|
|
if conv:
|
|
conv.updated_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(msg)
|
|
return msg
|
|
|
|
|
|
async def get_message(message_id: int) -> Message | None:
|
|
async with async_session() as session:
|
|
return await session.get(Message, message_id)
|
|
|
|
|
|
async def save_response_as_note(user_id: int, message_id: int) -> dict:
|
|
"""Create a note from an assistant message. Returns the new note dict."""
|
|
msg = await get_message(message_id)
|
|
if msg is None:
|
|
raise ValueError("Message not found")
|
|
if msg.role != "assistant":
|
|
raise ValueError("Can only save assistant messages as notes")
|
|
|
|
conv = await get_conversation(user_id, msg.conversation_id)
|
|
|
|
# Generate title via LLM using the assistant message content
|
|
title = ""
|
|
if conv:
|
|
model = conv.model or await get_setting(
|
|
user_id, "default_model", Config.OLLAMA_MODEL
|
|
)
|
|
try:
|
|
prompt_messages = [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"Generate a concise 3-8 word title for a note based on "
|
|
"this content. Reply with ONLY the title, no quotes or "
|
|
"punctuation."
|
|
),
|
|
},
|
|
{"role": "user", "content": msg.content[:2000]},
|
|
]
|
|
title = await generate_completion(prompt_messages, model)
|
|
title = title.strip().strip('"\'').strip()[:100]
|
|
except Exception:
|
|
logger.warning("Failed to generate note title, using fallback", exc_info=True)
|
|
|
|
if not title:
|
|
lines = msg.content.strip().split("\n", 1)
|
|
title = lines[0].strip().lstrip("# ")[:100]
|
|
|
|
note = await create_note(user_id, title=title, body=msg.content, tags=["chat"])
|
|
return note.to_dict()
|
|
|
|
|
|
async def summarize_conversation_as_note(
|
|
user_id: int, conversation_id: int, model: str
|
|
) -> dict:
|
|
"""Summarize a conversation using the LLM and save as a note."""
|
|
conv = await get_conversation(user_id, conversation_id)
|
|
if conv is None:
|
|
raise ValueError("Conversation not found")
|
|
|
|
# Build the conversation text
|
|
conv_text = []
|
|
for msg in conv.messages:
|
|
if msg.role == "system":
|
|
continue
|
|
label = "User" if msg.role == "user" else "Assistant"
|
|
conv_text.append(f"{label}: {msg.content}")
|
|
|
|
prompt_messages = [
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"Summarize the following conversation into a concise note. "
|
|
"Include key points, decisions, and any action items. "
|
|
"Format the summary in markdown."
|
|
),
|
|
},
|
|
{"role": "user", "content": "\n\n".join(conv_text)},
|
|
]
|
|
|
|
logger.info("Summarizing conversation %d with model %s", conversation_id, model)
|
|
summary = await generate_completion(prompt_messages, model)
|
|
|
|
title = conv.title or "Conversation Summary"
|
|
title = f"Summary: {title}"
|
|
|
|
note = await create_note(user_id, title=title, body=summary, tags=["chat"])
|
|
return note.to_dict()
|