Task work log, inline writing assistant, task editor sidebar layout
Backend: - Migration 0021: task_logs table (FK → notes + users, CASCADE, indexed) - models/task_log.py: SQLAlchemy model with to_dict() - services/task_logs.py: CRUD with ownership checks, _UNSET sentinel for optional duration clear - routes/task_logs.py: GET/POST/PATCH/DELETE /api/tasks/<id>/logs - services/tools.py: log_work LLM tool (resolves task by title, creates log entry) - services/generation_task.py: retry assist generation up to 3× on HTTP 500 Frontend: - types/task.ts: TaskLog interface - TaskLogSection.vue: chronological work log with date+time timestamps, duration badge, inline edit, autofocus - InlineAssistPanel.vue: streaming preview + diff review rendered inline in editor column - useAssist.ts: removed chatStore.chatReady gate; toast notifications for errors - NoteEditorView.vue + TaskEditorView.vue: inline assist panel, aside restricted to idle state - TaskEditorView.vue: two-column layout (editor+log left, metadata sidebar right), body defaults to Preview, sidebarOpen accordion for mobile - editor-shared.css: .assist-active-hint style Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from fabledassistant.routes.chat import chat_bp
|
||||
from fabledassistant.routes.notes import notes_bp
|
||||
from fabledassistant.routes.images import images_bp
|
||||
from fabledassistant.routes.milestones import milestones_bp
|
||||
from fabledassistant.routes.task_logs import task_logs_bp
|
||||
from fabledassistant.routes.projects import projects_bp
|
||||
from fabledassistant.routes.push import push_bp
|
||||
from fabledassistant.routes.quick_capture import quick_capture_bp
|
||||
@@ -55,6 +56,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(push_bp)
|
||||
app.register_blueprint(quick_capture_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(task_logs_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
|
||||
@app.before_request
|
||||
|
||||
@@ -29,3 +29,4 @@ from fabledassistant.models.project import Project # noqa: E402, F401
|
||||
from fabledassistant.models.push_subscription import PushSubscription # noqa: E402, F401
|
||||
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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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 TaskLog(Base):
|
||||
__tablename__ = "task_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
task_id: Mapped[int] = mapped_column(Integer, ForeignKey("notes.id", ondelete="CASCADE"))
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
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,
|
||||
"task_id": self.task_id,
|
||||
"user_id": self.user_id,
|
||||
"content": self.content,
|
||||
"duration_minutes": self.duration_minutes,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Task work log routes — /api/tasks/<task_id>/logs."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import get_current_user_id, login_required
|
||||
from fabledassistant.services.task_logs import create_log, delete_log, list_logs, update_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
task_logs_bp = Blueprint("task_logs", __name__, url_prefix="/api/tasks")
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs", methods=["GET"])
|
||||
@login_required
|
||||
async def list_logs_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
logs = await list_logs(uid, task_id)
|
||||
return jsonify({"logs": [l.to_dict() for l in logs]})
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs", methods=["POST"])
|
||||
@login_required
|
||||
async def create_log_route(task_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
content = (data or {}).get("content", "").strip()
|
||||
if not content:
|
||||
return jsonify({"error": "content is required"}), 400
|
||||
duration_minutes = (data or {}).get("duration_minutes")
|
||||
if duration_minutes is not None:
|
||||
try:
|
||||
duration_minutes = int(duration_minutes)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "duration_minutes must be an integer"}), 400
|
||||
try:
|
||||
log = await create_log(uid, task_id, content, duration_minutes)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
return jsonify(log.to_dict()), 201
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs/<int:log_id>", methods=["PATCH"])
|
||||
@login_required
|
||||
async def update_log_route(task_id: int, log_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json() or {}
|
||||
content = data.get("content")
|
||||
if content is not None:
|
||||
content = content.strip() or None
|
||||
|
||||
from fabledassistant.services.task_logs import _UNSET
|
||||
duration_minutes = data.get("duration_minutes", _UNSET)
|
||||
|
||||
log = await update_log(uid, log_id, content=content, duration_minutes=duration_minutes)
|
||||
if log is None:
|
||||
return jsonify({"error": "Log entry not found"}), 404
|
||||
return jsonify(log.to_dict())
|
||||
|
||||
|
||||
@task_logs_bp.route("/<int:task_id>/logs/<int:log_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
async def delete_log_route(task_id: int, log_id: int):
|
||||
uid = get_current_user_id()
|
||||
deleted = await delete_log(uid, log_id)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Log entry not found"}), 404
|
||||
return "", 204
|
||||
@@ -409,18 +409,40 @@ async def run_assist_generation(
|
||||
messages: list[dict],
|
||||
model: str,
|
||||
) -> None:
|
||||
"""Stream LLM response for assist into buffer. No DB persistence."""
|
||||
try:
|
||||
async for chunk in stream_chat(messages, model, options={"num_predict": 4096}):
|
||||
buf.content_so_far += chunk
|
||||
buf.append_event("chunk", {"chunk": chunk})
|
||||
"""Stream LLM response for assist into buffer. No DB persistence.
|
||||
|
||||
buf.state = GenerationState.COMPLETED
|
||||
buf.finished_at = time.monotonic()
|
||||
buf.append_event("done", {"done": True, "full_text": buf.content_so_far})
|
||||
Retries up to 3 times on Ollama 500 errors (model still loading).
|
||||
On each retry the accumulated content is reset so the done event
|
||||
always reflects only the successful generation.
|
||||
"""
|
||||
last_exc: BaseException | None = None
|
||||
for attempt in range(3):
|
||||
if attempt > 0:
|
||||
delay = 3.0 * attempt
|
||||
logger.warning(
|
||||
"Ollama assist stream 500 (attempt %d/3), retrying in %.0fs", attempt, delay
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
buf.content_so_far = ""
|
||||
async for chunk in stream_chat(messages, model, options={"num_predict": 4096}):
|
||||
buf.content_so_far += chunk
|
||||
buf.append_event("chunk", {"chunk": chunk})
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error in assist generation task")
|
||||
buf.state = GenerationState.ERRORED
|
||||
buf.finished_at = time.monotonic()
|
||||
buf.append_event("error", {"error": str(e)})
|
||||
buf.state = GenerationState.COMPLETED
|
||||
buf.finished_at = time.monotonic()
|
||||
buf.append_event("done", {"done": True, "full_text": buf.content_so_far})
|
||||
return
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
last_exc = exc
|
||||
if exc.response.status_code != 500:
|
||||
break
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
break
|
||||
|
||||
logger.exception("Error in assist generation task")
|
||||
buf.state = GenerationState.ERRORED
|
||||
buf.finished_at = time.monotonic()
|
||||
buf.append_event("error", {"error": str(last_exc)})
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Task work log service."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.task_log import TaskLog
|
||||
from fabledassistant.models.note import Note
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
async def create_log(
|
||||
user_id: int,
|
||||
task_id: int,
|
||||
content: str,
|
||||
duration_minutes: int | None = None,
|
||||
) -> TaskLog:
|
||||
async with async_session() as session:
|
||||
# Verify task exists and belongs to user
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.id == task_id, Note.user_id == user_id)
|
||||
)
|
||||
if result.scalars().first() is None:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
log = TaskLog(
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
duration_minutes=duration_minutes,
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
await session.refresh(log)
|
||||
return log
|
||||
|
||||
|
||||
async def list_logs(user_id: int, task_id: int) -> list[TaskLog]:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TaskLog)
|
||||
.where(TaskLog.task_id == task_id, TaskLog.user_id == user_id)
|
||||
.order_by(TaskLog.created_at.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def update_log(
|
||||
user_id: int,
|
||||
log_id: int,
|
||||
content: str | None = None,
|
||||
duration_minutes: object = _UNSET,
|
||||
) -> TaskLog | None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TaskLog).where(TaskLog.id == log_id, TaskLog.user_id == user_id)
|
||||
)
|
||||
log = result.scalars().first()
|
||||
if log is None:
|
||||
return None
|
||||
if content is not None:
|
||||
log.content = content
|
||||
if duration_minutes is not _UNSET:
|
||||
log.duration_minutes = duration_minutes # type: ignore[assignment]
|
||||
log.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(log)
|
||||
return log
|
||||
|
||||
|
||||
async def delete_log(user_id: int, log_id: int) -> bool:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TaskLog).where(TaskLog.id == log_id, TaskLog.user_id == user_id)
|
||||
)
|
||||
log = result.scalars().first()
|
||||
if log is None:
|
||||
return False
|
||||
await session.delete(log)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -440,6 +440,34 @@ _CORE_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "log_work",
|
||||
"description": (
|
||||
"Add a work log entry to a task to record progress, work done, or time spent. "
|
||||
"Use this when the user says they worked on, completed, or spent time on a task."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "Title or keyword identifying the task (required)",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Description of the work done (required)",
|
||||
},
|
||||
"duration_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Optional time spent in minutes",
|
||||
},
|
||||
},
|
||||
"required": ["task", "content"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# CalDAV tools — only included when user has CalDAV configured
|
||||
@@ -1455,6 +1483,33 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "log_work":
|
||||
from fabledassistant.services.task_logs import create_log as _create_log
|
||||
task_query = arguments.get("task", "")
|
||||
content = arguments.get("content", "").strip()
|
||||
if not task_query:
|
||||
return {"success": False, "error": "task is required"}
|
||||
if not content:
|
||||
return {"success": False, "error": "content is required"}
|
||||
duration_minutes = arguments.get("duration_minutes")
|
||||
if duration_minutes is not None:
|
||||
try:
|
||||
duration_minutes = int(duration_minutes)
|
||||
except (TypeError, ValueError):
|
||||
duration_minutes = None
|
||||
# Resolve task by exact title then fuzzy search (tasks only)
|
||||
note = await get_note_by_title(user_id, task_query)
|
||||
if note is None or note.status is None:
|
||||
notes, _ = await list_notes(user_id=user_id, q=task_query, is_task=True, limit=5)
|
||||
note = notes[0] if notes else None
|
||||
if note is None:
|
||||
return {"success": False, "error": f"No task found matching '{task_query}'."}
|
||||
try:
|
||||
log = await _create_log(user_id, note.id, content, duration_minutes)
|
||||
except ValueError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
return {"success": True, "log": log.to_dict(), "task": note.title}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user