diff --git a/.env.example b/.env.example index 9a8ee7b..95aecb0 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,12 @@ TOOL_ROUTING_MODE=static # Optional durable summary sidecar; defaults to CHECKPOINT_DB_PATH + .summaries.db # Store on the same persistent shared volume as the checkpoints. # CONVERSATION_SUMMARY_DB_PATH=/app/checkpoints/conversation-summaries.db +# Durable chat runs (see docs/chat-runs.md). Lease = abandoned-worker timeout; +# max = whole generation; drain = shutdown wait for in-flight generations. +# scripts/deploy.sh gives the old container drain + 60s (override DEPLOY_STOP_SECONDS). +CHAT_RUN_LEASE_SECONDS=30 +CHAT_RUN_MAX_SECONDS=1800 +CHAT_RUN_DRAIN_SECONDS=1800 # Embedding Server Configuration # If set, uses a custom OpenAI-compatible embedding server (e.g. llama-server) diff --git a/app.py b/app.py index 222196d..36591ef 100644 --- a/app.py +++ b/app.py @@ -17,6 +17,14 @@ from quart_jwt_extended import JWTManager, get_jwt_identity from tortoise import Tortoise import blueprints.artifacts +import blueprints.integrations +from blueprints.conversation.history import history_blueprint +from blueprints.conversation.runs import ( + drain_runs, + expire_runs, + runs_blueprint, +) +from blueprints.rag.source_api import sources_blueprint import blueprints.conversation import blueprints.conversation.logic import blueprints.email @@ -75,6 +83,10 @@ app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprin app.register_blueprint(blueprints.cron.cron_blueprint) app.register_blueprint(blueprints.beli.beli_blueprint) app.register_blueprint(blueprints.artifacts.artifacts_blueprint) +app.register_blueprint(blueprints.integrations.integrations_blueprint) +app.register_blueprint(history_blueprint) +app.register_blueprint(runs_blueprint) +app.register_blueprint(sources_blueprint) async def _obsidian_sync_loop(): @@ -104,6 +116,7 @@ async def lifespan(): logging.info("Initializing Tortoise ORM...") await Tortoise.init(config=TORTOISE_CONFIG) logging.info("Tortoise ORM initialized successfully") + await expire_runs() import aiosqlite from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver @@ -138,6 +151,9 @@ async def lifespan(): yield + # Let in-flight chat runs finish while the agent and checkpointer are alive. + await drain_runs(app) + for task in background_tasks: task.cancel() @@ -254,4 +270,15 @@ async def get_messages(): if __name__ == "__main__": - app.run(host="0.0.0.0", port=8080, debug=True) + from hypercorn.asyncio import serve + from hypercorn.config import Config + + config = Config() + config.bind = ["0.0.0.0:8080"] + config.accesslog = "-" + config.errorlog = "-" + # SSE subscribers reconnect to the new deployment, so connections close + # quickly; lifespan shutdown must outlast chat-run draining (drain_runs). + config.graceful_timeout = 10 + config.shutdown_timeout = float(os.getenv("CHAT_RUN_DRAIN_SECONDS", "1800")) + 30 + asyncio.run(serve(app, config)) diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index d251734..a688149 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -7,8 +7,7 @@ import time import uuid from urllib.parse import quote, unquote -from quart import Blueprint, current_app, jsonify, make_response, request -from langchain_core.messages import convert_to_messages +from quart import Blueprint, current_app, jsonify, request from quart_jwt_extended import get_jwt_identity import blueprints.users.models @@ -35,11 +34,11 @@ from utils.s3_client import upload_image as s3_upload_image from .logic import ( add_message_to_conversation, - get_agent_for, persist_agent_tool_messages, tool_result_text, ) -from .memory import build_system_prompt_with_memories +from . import runtime +from .runtime import prepare_summary as _prepare_conversation_summary # noqa: F401 — compatibility export from .models import ( Conversation, PydConversation, @@ -135,17 +134,6 @@ conversation_blueprint = Blueprint( "conversation_api", __name__, url_prefix="/api/conversation" ) -# Detached persistence tasks. A strong reference keeps them from being garbage -# collected mid-flight when the SSE stream is cancelled by a client disconnect. -_persist_tasks: set[asyncio.Task] = set() - - -def _persist_in_background(coro) -> asyncio.Task: - task = asyncio.create_task(coro) - _persist_tasks.add(task) - task.add_done_callback(_persist_tasks.discard) - return task - async def _get_owned_conversation(conversation_id, user_id) -> Conversation | None: """Fetch a conversation only if it belongs to the given user.""" @@ -171,38 +159,30 @@ def _build_user_message( return {"role": "user", "content": content} -async def _prepare_conversation_summary(agent, payload, config) -> None: - """Archive completed checkpoint turns before adding this request's message.""" - store = getattr(current_app, "conversation_summary_store", None) - if store is None: - return - configurable = config["configurable"] - try: - checkpoint = await agent.aget_state(config) - history = convert_to_messages(checkpoint.values.get("messages", [])) - state = await store.update( - configurable["user_id"], - configurable["thread_id"], - history + convert_to_messages(payload["messages"]), - ) - configurable["conversation_summary"] = state.as_message() - except Exception: - # The original checkpoint still supplies recent history if the optional - # summary store is unavailable. Don't fail an otherwise valid request. - logging.exception("Failed to prepare conversation summary") - - @conversation_blueprint.post("/query") @jwt_access_required async def query(): current_user_uuid = get_jwt_identity() user = await blueprints.users.models.User.get(id=current_user_uuid) - data = await request.get_json() + data = await request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "JSON object required"}), 400 query = data.get("query") + if not isinstance(query, str) or not query.strip(): + return jsonify({"error": "query must be a non-empty string"}), 400 conversation_id = data.get("conversation_id") + if not isinstance(conversation_id, str): + return jsonify({"error": "conversation not found"}), 404 conversation = await _get_owned_conversation(conversation_id, current_user_uuid) if conversation is None: return jsonify({"error": "conversation not found"}), 404 + + from .run_models import ChatRun + + # A durable run owns this checkpoint thread (possibly in another process). + if await ChatRun.filter(active_key=str(conversation.id)).exists(): + return jsonify({"error": "A run is already active in this conversation"}), 409 + await add_message_to_conversation( conversation=conversation, message=query, @@ -211,22 +191,9 @@ async def query(): ) with time_response(stream=False): - system_prompt = await build_system_prompt_with_memories( - str(user.id), query=query or "" - ) - payload = {"messages": [_build_user_message(query)]} - agent_config = { - "configurable": { - "user_id": str(user.id), - "is_admin": user.is_admin(), - "thread_id": str(conversation.id), - "system_prompt": system_prompt, - } - } - - main_agent = get_agent_for(user) - await _prepare_conversation_summary(main_agent, payload, agent_config) - response = await main_agent.ainvoke(payload, config=agent_config) + async with runtime.thread_lock(str(conversation.id)): + turn = await runtime.prepare_agent_turn(user, query, str(conversation.id)) + response = await runtime.invoke_prepared(turn) message = extract_agent_text(response.get("messages", [])[-1].content) await persist_agent_tool_messages( conversation, user, response.get("messages", []) @@ -365,23 +332,31 @@ async def serve_image(image_key: str): @conversation_blueprint.post("/stream-query") @jwt_access_required async def stream_query(): - current_user_uuid = get_jwt_identity() - user = await blueprints.users.models.User.get(id=current_user_uuid) - data = await request.get_json() + # Compatibility adapter: disconnect detaches; explicit cancellation uses /runs. + from .runs import create_run, replay_response, RunConflict + + try: + run, _ = await create_run( + current_app._get_current_object(), + get_jwt_identity(), + await request.get_json(), + ) + except RunConflict as exc: + return jsonify({"error": str(exc)}), exc.status + return await replay_response(run, 0) + + +async def _chat_run_stream(data, user, conversation, save_user, save_answer): query_text = data.get("query") - conversation_id = data.get("conversation_id") image_key = data.get("image_key") file_key = data.get("file_key") - conversation = await _get_owned_conversation(conversation_id, current_user_uuid) - if conversation is None: - return jsonify({"error": "conversation not found"}), 404 # An attached file is extracted and appended to the agent's user turn; the # saved message only records its name. saved_text = query_text or "" if file_key: if not _file_key_belongs_to(file_key, conversation.id): - return jsonify({"error": "invalid file_key"}), 400 + raise ValueError("invalid file_key") try: file_name, file_text = await _load_attached_file(file_key) block = attachment_block(file_name, file_text) @@ -392,13 +367,7 @@ async def stream_query(): query_text = f"{query_text or 'The user attached this file.'}\n\n{block}" saved_text = f"{saved_text}\n\nšŸ“Ž {file_name}".strip() - await add_message_to_conversation( - conversation=conversation, - message=saved_text, - speaker="user", - user=user, - image_key=image_key, - ) + await save_user(saved_text, image_key) # If an image was uploaded, attach it to the agent request directly image_block = None @@ -410,23 +379,12 @@ async def stream_query(): logging.error(f"Failed to load image {image_key}: {e}") query_text = f"{query_text or ''}\n\n[An image was sent but could not be loaded.]".strip() - system_prompt = await build_system_prompt_with_memories( - str(user.id), query=saved_text or "" + turn = await runtime.prepare_agent_turn( + user, + saved_text or "", + str(conversation.id), + messages=[_build_user_message(query_text or "", image_block)], ) - payload = {"messages": [_build_user_message(query_text or "", image_block)]} - agent_config = { - "configurable": { - "user_id": str(user.id), - "is_admin": user.is_admin(), - "thread_id": str(conversation.id), - "system_prompt": system_prompt, - } - } - # Capture the agent reference while the request/app context is still active. - # Quart exits the app context before iterating the SSE response body, so - # current_app cannot be accessed inside event_generator(). - main_agent = get_agent_for(user) - await _prepare_conversation_summary(main_agent, payload, agent_config) async def event_generator(): final_message = None @@ -450,15 +408,16 @@ async def stream_query(): content_chunks = 0 model_name: str | None = None cancelled = False - persistence_task: asyncio.Task | None = None + persist_attempted = False async def persist(text: str) -> None: - await add_message_to_conversation( - conversation=conversation, - message=text, - speaker="simba", - user=user, - ) + # At most one attempt: a save cancelled mid-flight must not be retried + # by the cancellation handler, or the answer could be stored twice. + nonlocal persist_attempted + if persist_attempted: + return + persist_attempted = True + await save_answer(text) def _log_summary(*, error: str | None = None) -> None: now = time.monotonic() @@ -502,9 +461,7 @@ async def stream_query(): ) try: - aiter = main_agent.astream_events( - payload, version="v2", config=agent_config - ).__aiter__() + aiter = runtime.stream_prepared(turn).__aiter__() while True: # Only a silent model call counts as a stall. A model that is # thinking (or streaming empty chunks) keeps resetting the clock, @@ -652,9 +609,8 @@ async def stream_query(): final_message = final_message or streamed if final_message: - # Shield the single persistence task across client disconnects. - persistence_task = _persist_in_background(persist(final_message)) - await asyncio.shield(persistence_task) + # The durable worker owns persistence independently of subscribers. + await persist(final_message) yield f"data: {json.dumps({'type': 'response', 'message': final_message})}\n\n" else: yield f"data: {json.dumps({'type': 'error', 'message': 'No response generated'})}\n\n" @@ -664,9 +620,9 @@ async def stream_query(): count_result(stream=True, result="cancelled") observe_response(stream=True, seconds=time.monotonic() - t0) _log_summary() - text = final_message or streamed - if text and persistence_task is None: - _persist_in_background(persist(text)) + text = final_message or (streamed + (tag_buf if not in_thinking else "")) + if text: + await persist(text) raise except Exception as e: count_result(stream=True, result="error") @@ -680,19 +636,8 @@ async def stream_query(): _log_summary(error=None if final_message else "No response generated") yield "data: [DONE]\n\n" - response = await make_response( - event_generator(), - 200, - { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - ) - # Quart's default 60s response deadline cancels even active SSE streams. - # Silent model calls are bounded separately by MODEL_STALL_TIMEOUT. - response.timeout = None - return response + async for frame in event_generator(): + yield frame @conversation_blueprint.route("/") diff --git a/blueprints/conversation/agents.py b/blueprints/conversation/agents.py index 0581346..e7927ef 100644 --- a/blueprints/conversation/agents.py +++ b/blueprints/conversation/agents.py @@ -140,17 +140,28 @@ async def web_search(query: str) -> str: @tool(response_format="content_and_artifact") -async def simba_search(query: str): +async def simba_search( + query: str, start_date: str = "", end_date: str = "", k: int = 8 +): """Search Simba's medical records, vet documents, and household documents. Args: query: Question or information need + start_date: Inclusive document date YYYY-MM-DD, or empty for all dates. + end_date: Inclusive document date YYYY-MM-DD, or empty for all dates. + k: Maximum results, from 1 to 20. """ - print(f"[SIMBA SEARCH] Tool called with query: {query}") - serialized, docs = await query_vector_store(query=query, source="paperless") - print(f"[SIMBA SEARCH] Found {len(docs)} documents") - print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}") - print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}") + try: + kwargs = ( + {"start_date": start_date, "end_date": end_date, "k": k} + if start_date or end_date or k != 8 + else {} + ) + serialized, docs = await query_vector_store( + query=query, source="paperless", **kwargs + ) + except ValueError as exc: + return str(exc), None return serialized, search_documents_artifact(query, "paperless", docs) @@ -453,18 +464,30 @@ def _note_artifact(path: str, parsed: dict, title: str | None = None) -> dict: @tool(response_format="content_and_artifact") -async def obsidian_search_notes(query: str): +async def obsidian_search_notes( + query: str, start_date: str = "", end_date: str = "", k: int = 8 +): """Search personal Obsidian vault notes. Args: query: Search query + start_date: Inclusive note date YYYY-MM-DD, or empty for all dates. + end_date: Inclusive note date YYYY-MM-DD, or empty for all dates. + k: Maximum results, from 1 to 20. """ if not obsidian_enabled: return OBSIDIAN_NOT_CONFIGURED, None try: # Query vector store filtered to obsidian source only - serialized, docs = await query_vector_store(query=query, source="obsidian") + kwargs = ( + {"start_date": start_date, "end_date": end_date, "k": k} + if start_date or end_date or k != 8 + else {} + ) + serialized, docs = await query_vector_store( + query=query, source="obsidian", **kwargs + ) return serialized, search_documents_artifact(query, "obsidian", docs) except Exception as e: diff --git a/blueprints/conversation/artifacts_util.py b/blueprints/conversation/artifacts_util.py index cfe64af..48f4e58 100644 --- a/blueprints/conversation/artifacts_util.py +++ b/blueprints/conversation/artifacts_util.py @@ -148,9 +148,16 @@ def search_documents_artifact(query: str, source: str, docs) -> dict | None: if not docs: return None results = [] + from blueprints.rag.retrieval import citation_url + for doc in docs: meta = doc.metadata or {} result = {"snippet": snippet(doc.page_content)} + if url := citation_url(doc): + result["url"] = url + result["source_id"] = str(doc.id) + if headings := [meta[k] for k in ("h1", "h2", "h3") if meta.get(k)]: + result["headings"] = headings if source == "obsidian": filepath = meta.get("filepath") or "" path = vault_relative_path(filepath) if filepath else "" diff --git a/blueprints/conversation/history.py b/blueprints/conversation/history.py new file mode 100644 index 0000000..3a4f0ce --- /dev/null +++ b/blueprints/conversation/history.py @@ -0,0 +1,277 @@ +"""Owner-scoped history and memory management; register history_blueprint in app.""" + +import re +from uuid import UUID + +from quart import Blueprint, Response, jsonify, request +from quart_jwt_extended import get_jwt_identity +from tortoise.expressions import Q + +from blueprints.users.decorators import jwt_access_required +from .models import Conversation, ConversationMessage, Speaker, UserMemory + +history_blueprint = Blueprint("history_api", __name__, url_prefix="/api/history") + + +def valid_id(value): + try: + return UUID(str(value)) + except (ValueError, TypeError, AttributeError): + return None + + +def pagination(limit=20, offset=0): + try: + limit, offset = int(limit), int(offset) + except (TypeError, ValueError): + raise ValueError("limit and offset must be integers") from None + if not 1 <= limit <= 100 or offset < 0: + raise ValueError("limit must be 1–100 and offset must be nonnegative") + return limit, offset + + +def display_text(message): + return ( + message.text.split("\n", 1)[0] + if message.speaker == Speaker.TOOL + else message.text + ) + + +def snippet(text, search): + index = text.casefold().find(search.casefold()) + start = max(0, index - 60) + end = min(len(text), start + 200) + return ("…" if start else "") + text[start:end] + ("…" if end < len(text) else "") + + +async def list_owned_conversations(user_id, search=None, limit=20, offset=0): + """Stable pinned-first pagination. DISTINCT runs before LIMIT on message joins.""" + limit, offset = pagination(limit, offset) + if search is not None and (not isinstance(search, str) or len(search) > 500): + raise ValueError("search must be text of at most 500 characters") + search = (search or "").strip() + query = Conversation.filter(user_id=user_id) + if search: + query = query.filter( + Q(name__icontains=search) | Q(messages__text__icontains=search) + ) + conversations = ( + await query.distinct() + .order_by("-pinned", "-updated_at", "-id") + .offset(offset) + .limit(limit) + ) + rows = [] + for conversation in conversations: + row = { + "id": str(conversation.id), + "name": conversation.name, + "pinned": conversation.pinned, + "created_at": conversation.created_at.isoformat(), + "updated_at": conversation.updated_at.isoformat(), + } + if search: + match = ( + await ConversationMessage.filter( + conversation_id=conversation.id, text__icontains=search + ) + .order_by("created_at", "id") + .first() + ) + row["snippet"] = ( + snippet(display_text(match), search) + if match + else snippet(conversation.name, search) + ) + if ( + match + and match.speaker == Speaker.TOOL + and search.casefold() not in display_text(match).casefold() + ): + row["snippet"] = ( + "Match in stored tool context (hidden in chat): " + + display_text(match)[:150] + ) + row["match_message_id"] = str(match.id) if match else None + rows.append(row) + return rows + + +@history_blueprint.get("/conversations") +@jwt_access_required +async def conversations(): + try: + rows = await list_owned_conversations( + get_jwt_identity(), + request.args.get("search"), + request.args.get("limit", 20), + request.args.get("offset", 0), + ) + except ValueError as exc: + return jsonify(error=str(exc)), 400 + return jsonify(rows) + + +async def owned_conversation(conversation_id): + identifier = valid_id(conversation_id) + if identifier is None: + return None + return await Conversation.get_or_none(id=identifier, user_id=get_jwt_identity()) + + +@history_blueprint.patch("/conversations//pin") +@jwt_access_required +async def pin(conversation_id): + conversation = await owned_conversation(conversation_id) + if conversation is None: + return jsonify(error="conversation not found"), 404 + data = await request.get_json(silent=True) + if not isinstance(data, dict) or type(data.get("pinned")) is not bool: + return jsonify(error="pinned must be a boolean"), 400 + conversation.pinned = data["pinned"] + # Pinning must not change conversation recency. + await conversation.save(update_fields=["pinned"]) + return jsonify(id=str(conversation.id), pinned=conversation.pinned) + + +async def export_owned_conversation(conversation): + messages = await ConversationMessage.filter( + conversation_id=conversation.id + ).order_by("created_at", "id") + exported = [] + for message in messages: + text = display_text(message) + attachments = [ + {"kind": "file", "recorded_name": name, "contents_included": False} + for name in re.findall(r"^šŸ“Ž (.+)$", text, re.MULTILINE) + ] + if message.image_key: + # Stored references can be malformed or point outside this owner; never resolve or export keys. + attachments.append( + { + "kind": "image", + "stored_reference_present": True, + "contents_included": False, + } + ) + exported.append( + { + "id": str(message.id), + "speaker": message.speaker.value, + "created_at": message.created_at.isoformat(), + "text": text, + "provenance": ( + "displayed_tool_header" + if message.speaker == Speaker.TOOL + else "stored_message_text" + ), + "raw_tool_context_included": False, + "attachments": attachments, + } + ) + return { + "version": 1, + "id": str(conversation.id), + "name": conversation.name, + "pinned": conversation.pinned, + "created_at": conversation.created_at.isoformat(), + "updated_at": conversation.updated_at.isoformat(), + "messages": exported, + "export_note": "Stored display text only. Raw tool context, artifacts, attachment contents and storage references are omitted. File names are recorded labels, not verified files.", + } + + +@history_blueprint.get("/conversations//export") +@jwt_access_required +async def export(conversation_id): + conversation = await owned_conversation(conversation_id) + if conversation is None: + return jsonify(error="conversation not found"), 404 + format_name = request.args.get("format", "json") + if format_name not in ("json", "markdown"): + return jsonify(error="format must be json or markdown"), 400 + data = await export_owned_conversation(conversation) + if format_name == "json": + response = jsonify(data) + extension = "json" + else: + parts = [f"# {data['name']}", data["export_note"]] + for message in data["messages"]: + parts.extend( + [ + f"## {message['speaker']} — {message['created_at']}", + message["text"], + f"Provenance: {message['provenance']}.", + ] + ) + if message["attachments"]: + parts.append( + "Attachment contents omitted; recorded labels appear in the stored text." + ) + response = Response( + "\n\n".join(parts), content_type="text/markdown; charset=utf-8" + ) + extension = "md" + response.headers["Content-Disposition"] = ( + f'attachment; filename="conversation-{conversation.id}.{extension}"' + ) + response.headers["Cache-Control"] = "no-store" + return response + + +def memory_row(memory): + return { + "id": str(memory.id), + "content": memory.content, + "created_at": memory.created_at.isoformat(), + "updated_at": memory.updated_at.isoformat(), + } + + +@history_blueprint.get("/memories") +@jwt_access_required +async def memories(): + try: + limit, offset = pagination( + request.args.get("limit", 100), request.args.get("offset", 0) + ) + except ValueError as exc: + return jsonify(error=str(exc)), 400 + rows = ( + await UserMemory.filter(user_id=get_jwt_identity()) + .order_by("-updated_at", "-id") + .offset(offset) + .limit(limit) + ) + return jsonify([memory_row(row) for row in rows]) + + +@history_blueprint.route("/memories/", methods=["PATCH", "DELETE"]) +@jwt_access_required +async def change_memory(memory_id): + identifier = valid_id(memory_id) + memory = ( + await UserMemory.get_or_none(id=identifier, user_id=get_jwt_identity()) + if identifier + else None + ) + if memory is None: + return jsonify(error="memory not found"), 404 + if request.method == "DELETE": + await memory.delete() + return "", 204 + data = await request.get_json(silent=True) + if ( + not isinstance(data, dict) + or not isinstance(data.get("content"), str) + or not data["content"].strip() + or len(data["content"]) > 10000 + ): + return ( + jsonify(error="content must be nonempty text of at most 10000 characters"), + 400, + ) + memory.content = data["content"].strip() + await memory.save(update_fields=["content", "updated_at"]) + return jsonify(memory_row(memory)) diff --git a/blueprints/conversation/models.py b/blueprints/conversation/models.py index 69e8c8e..db66898 100644 --- a/blueprints/conversation/models.py +++ b/blueprints/conversation/models.py @@ -23,6 +23,7 @@ class Conversation(Model): "models.User", related_name="conversations", null=True ) channel = fields.CharField(max_length=20, default="web", null=True) + pinned = fields.BooleanField(default=False) class Meta: table = "conversations" diff --git a/blueprints/conversation/prompts.py b/blueprints/conversation/prompts.py index 570f55d..84c0200 100644 --- a/blueprints/conversation/prompts.py +++ b/blueprints/conversation/prompts.py @@ -32,6 +32,8 @@ Upcoming Appointments: IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions. +SOURCE CITATIONS: When using retrieved documents, cite the provided Citation URLs with Markdown links. Use only URLs returned by tools; never invent source IDs or references. A citation opens the exact indexed section, which may differ from a newer original. Document date filters refer to indexed document metadata, not necessarily the date of every event described in the document. + BUDGET & FINANCE (YNAB Integration): You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools: - Use ynab_budget_summary for a month's overview (Ready to Assign, income, spent, overspending, net worth) diff --git a/blueprints/conversation/run_models.py b/blueprints/conversation/run_models.py new file mode 100644 index 0000000..f6667d5 --- /dev/null +++ b/blueprints/conversation/run_models.py @@ -0,0 +1,42 @@ +"""Durable turn ownership and append-only replay log (shared PostgreSQL/SQLite).""" + +from tortoise import fields +from tortoise.models import Model + + +class ChatRun(Model): + id = fields.UUIDField(primary_key=True) + conversation = fields.ForeignKeyField( + "models.Conversation", related_name="chat_runs" + ) + owner = fields.ForeignKeyField("models.User", related_name="chat_runs") + request_key = fields.CharField(max_length=128) + payload = fields.JSONField() + # Unique nullable slot: only one generation can write a conversation checkpoint. + active_key = fields.CharField(max_length=36, null=True, unique=True) + status = fields.CharField(max_length=20, default="running") + worker_token = fields.UUIDField() + lease_until = fields.DatetimeField(index=True) + cancel_requested = fields.BooleanField(default=False) + sequence = fields.IntField(default=0) + partial = fields.TextField(default="") + user_saved = fields.BooleanField(default=False) + answer_saved = fields.BooleanField(default=False) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "chat_runs" + unique_together = (("owner", "conversation", "request_key"),) + + +class ChatRunEvent(Model): + id = fields.BigIntField(primary_key=True) + run = fields.ForeignKeyField("models.ChatRun", related_name="events") + sequence = fields.IntField() + payload = fields.JSONField() + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "chat_run_events" + unique_together = (("run", "sequence"),) diff --git a/blueprints/conversation/runs.py b/blueprints/conversation/runs.py new file mode 100644 index 0000000..f5c47b8 --- /dev/null +++ b/blueprints/conversation/runs.py @@ -0,0 +1,576 @@ +"""A browser connection is only a subscriber, never the owner of generation.""" + +import asyncio +import json +import logging +import os +import time +import uuid +import weakref +from datetime import datetime, timedelta, timezone +from contextlib import aclosing +from contextvars import Context + +from quart import Blueprint, current_app, jsonify, make_response, request +from quart_jwt_extended import get_jwt_identity +from tortoise.exceptions import IntegrityError +from tortoise.transactions import in_transaction + +from blueprints.users.decorators import jwt_access_required +from blueprints.users.models import User +from .models import Conversation +from .logic import add_message_to_conversation +from . import runtime +from .run_models import ChatRun, ChatRunEvent + +runs_blueprint = Blueprint("chat_runs", __name__, url_prefix="/api/conversation") +LEASE_SECONDS = max(10, float(os.getenv("CHAT_RUN_LEASE_SECONDS", "30"))) +MAX_SECONDS = max(1, float(os.getenv("CHAT_RUN_MAX_SECONDS", "1800"))) +_tasks = {} # strong refs, keyed by run id; DB is the authority across processes +# Same-process subscribers wait on these instead of polling; other processes +# still see new events through the polling fallback. +# Weak values: an entry lives only while some subscriber is waiting on it. +_wakeups: weakref.WeakValueDictionary[str, asyncio.Event] = ( + weakref.WeakValueDictionary() +) +# Streamed deltas are coalesced so each write carries ~this much text. +FLUSH_SECONDS = 0.15 +POLL_SECONDS = 0.5 +_DELTA_TYPES = ("content", "thinking_delta") + + +def _notify(run_id): + event = _wakeups.pop(str(run_id), None) + if event is not None: + event.set() + + +def _waiter(run_id): + event = _wakeups.get(str(run_id)) + if event is None: + event = _wakeups[str(run_id)] = asyncio.Event() + return event + + +_draining_apps = set() + + +def now(): + return datetime.now(timezone.utc) + + +class RunConflict(Exception): + def __init__(self, message, status=409): + super().__init__(message) + self.status = status + + +async def owned_run(owner, conversation_id, run_id): + try: + return await ChatRun.get_or_none( + id=run_id, + owner_id=owner, + conversation_id=conversation_id, + conversation__user_id=owner, + ) + except (ValueError, TypeError): + return None + + +async def _locked(run_id, db): + # UPDATE takes a row lock on PostgreSQL and a write lock on SQLite, before reads. + await ChatRun.filter(id=run_id).using_db(db).update(updated_at=now()) + return await ChatRun.get_or_none(id=run_id).using_db(db) + + +async def _save_answer(run, text): + if text and not run.answer_saved: + conversation = await Conversation.get(id=run.conversation_id) + user = await User.get(id=run.owner_id) + await add_message_to_conversation(conversation, text, "simba", user) + run.answer_saved = True + + +async def append(run_id, token, payload): + async with in_transaction() as db: + run = await _locked(run_id, db) + if ( + run.status != "running" + or run.cancel_requested + or str(run.worker_token) != str(token) + ): + raise asyncio.CancelledError() + run.sequence += 1 + if payload.get("type") == "content": + run.partial += payload.get("delta", "") + await ChatRunEvent.create( + run_id=run.id, sequence=run.sequence, payload=payload, using_db=db + ) + await run.save(using_db=db) + _notify(run_id) + + +async def finish(run_id, status, payload=None, token=None, tail=""): + """Terminalize a run; `tail` is buffered content never appended as an event.""" + async with in_transaction() as db: + run = await _locked(run_id, db) + if ( + run is None + or run.status != "running" + or (token and str(run.worker_token) != str(token)) + ): + return + run.partial += tail + await _save_answer(run, run.partial) + if run.cancel_requested and status == "completed": + status = "cancelled" + run.status = status + run.active_key = None + for event in ([payload] if payload else []) + [ + {"type": "done", "status": status} + ]: + run.sequence += 1 + await ChatRunEvent.create( + run_id=run.id, sequence=run.sequence, payload=event, using_db=db + ) + await run.save(using_db=db) + _notify(run_id) + + +async def expire_runs(conversation_id=None): + query = ChatRun.filter(status="running", lease_until__lt=now()) + if conversation_id: + query = query.filter(conversation_id=conversation_id) + for candidate in await query: + # Recheck under lock; a heartbeat may have arrived after the initial scan. + async with in_transaction() as db: + run = await _locked(candidate.id, db) + if run is None or run.status != "running" or run.lease_until >= now(): + continue + await finish( + run.id, + "cancelled" if run.cancel_requested else "interrupted", + { + "type": "error", + "message": "Generation was interrupted by a server restart. Please retry.", + }, + ) + + +async def create_run(app, owner, data): + if not isinstance(data, dict): + raise RunConflict("JSON object required", 400) + conversation_id = data.get("conversation_id") + try: + conversation = await Conversation.get_or_none(id=conversation_id, user_id=owner) + except (ValueError, TypeError): + conversation = None + if conversation is None: + raise RunConflict("conversation not found", 404) + key = data.get("request_id", str(uuid.uuid4())) + if not isinstance(key, str) or not key or len(key) > 128: + raise RunConflict("request_id must be 1–128 characters", 400) + payload = {k: data.get(k) for k in ("query", "image_key", "file_key")} + if not isinstance(payload["query"], str): + raise RunConflict("query must be a string", 400) + for name, prefix in (("image_key", ""), ("file_key", "files/")): + value = payload[name] + # Matches the worker's checks, so a run is never created only to fail. + if value and ( + not isinstance(value, str) + or ".." in value + or not value.startswith(f"conversations/{conversation.id}/{prefix}") + ): + raise RunConflict(f"invalid {name}", 400) + await expire_runs(str(conversation.id)) + + async def existing(): + run = await ChatRun.get_or_none( + owner_id=owner, conversation_id=conversation.id, request_key=key + ) + if run and run.payload != payload: + raise RunConflict("request_id already used with different input") + return run + + run = await existing() + if run: + return run, False + if id(app) in _draining_apps: + raise RunConflict("Server is draining; retry creation shortly", 503) + token = uuid.uuid4() + try: + run = await ChatRun.create( + owner_id=owner, + conversation_id=conversation.id, + request_key=key, + payload=payload, + active_key=str(conversation.id), + worker_token=token, + lease_until=now() + timedelta(seconds=LEASE_SECONDS), + ) + except IntegrityError: + run = await existing() + if run: + return run, False + raise RunConflict("A run is already active in this conversation") + task = asyncio.create_task(_work(app, run.id, token), context=Context()) + _tasks[str(run.id)] = (app, task) + + def completed(task): + _tasks.pop(str(run.id), None) + if not task.cancelled() and task.exception(): + logging.error( + "Chat run worker exited before finalization", exc_info=task.exception() + ) + + task.add_done_callback(completed) + return run, True + + +class _DeltaBatch: + """Coalesce streamed deltas so a run writes ~FLUSH_SECONDS of text per event.""" + + def __init__(self, run_id, token): + self.run_id, self.token = run_id, token + self.frames: asyncio.Queue = asyncio.Queue() # raw SSE frames not yet handled + self.pending = None + self.last_flush = 0.0 + + def due(self): + """Seconds until the pending delta must be written; None if nothing waits.""" + if self.pending is None: + return None + return max(0.0, FLUSH_SECONDS - (time.monotonic() - self.last_flush)) + + def unsent_content(self): + """Answer text that was produced but never written as an event.""" + parts = [] + if self.pending is not None and self.pending["type"] == "content": + parts.append(self.pending["delta"]) + while not self.frames.empty(): + item = self.frames.get_nowait() + if isinstance(item, str) and item.startswith("data: {"): + payload = json.loads(item.removeprefix("data: ")) + if payload.get("type") == "content": + parts.append(payload.get("delta", "")) + return "".join(parts) + + async def flush(self): + if self.pending is not None: + await append(self.run_id, self.token, self.pending) + self.pending = None + self.last_flush = time.monotonic() + + async def add(self, payload): + if payload.get("type") not in _DELTA_TYPES: + await self.flush() + await append(self.run_id, self.token, payload) + elif self.pending is not None and self.pending["type"] == payload["type"]: + self.pending["delta"] += payload.get("delta", "") + else: + await self.flush() + self.pending = {**payload, "delta": payload.get("delta", "")} + + +async def _heartbeat(run_id, token, worker): + try: + while True: + await asyncio.sleep(min(1, LEASE_SECONDS / 3)) + changed = await ChatRun.filter( + id=run_id, + worker_token=token, + status="running", + cancel_requested=False, + lease_until__gte=now(), + ).update(lease_until=now() + timedelta(seconds=LEASE_SECONDS)) + if not changed: + worker.cancel() + return + except Exception: + logging.exception("Chat run heartbeat failed") + worker.cancel() + + +async def _work(app, run_id, token): + heartbeat = asyncio.create_task(_heartbeat(run_id, token, asyncio.current_task())) + batch = _DeltaBatch(run_id, token) + async with app.app_context(): + try: + async with asyncio.timeout(MAX_SECONDS): + from . import _chat_run_stream + + run = await ChatRun.get(id=run_id) + conversation = await Conversation.get(id=run.conversation_id) + user = await User.get(id=run.owner_id) + + async def save_user(text, image_key): + async with in_transaction() as db: + locked = await _locked(run_id, db) + if ( + locked is None + or locked.status != "running" + or locked.cancel_requested + ): + raise asyncio.CancelledError() + if not locked.user_saved: + await add_message_to_conversation( + conversation, text, "user", user, image_key=image_key + ) + locked.user_saved = True + await locked.save(using_db=db) + + async def save_answer(text): + async with in_transaction() as db: + locked = await _locked(run_id, db) + if locked is not None and locked.status == "running": + await _save_answer(locked, text) + await locked.save(using_db=db) + + failed = False + async with ( + runtime.thread_lock(str(conversation.id)), + aclosing( + _chat_run_stream( + {**run.payload, "conversation_id": str(conversation.id)}, + user, + conversation, + save_user, + save_answer, + ) + ) as stream, + ): + # The generator runs in one task (keeping its context across + # steps) so buffered text can be flushed on a timer even while + # the model emits nothing, e.g. while writing tool arguments. + frames = batch.frames + + async def produce(): + try: + async for frame in stream: + frames.put_nowait(frame) + frames.put_nowait(None) + except BaseException as exc: + frames.put_nowait(exc) + raise + + producer = asyncio.create_task(produce()) + try: + while True: + try: + item = await asyncio.wait_for(frames.get(), batch.due()) + except TimeoutError: + await batch.flush() + continue + if item is None: + break + if isinstance(item, BaseException): + raise item + raw = item.removeprefix("data: ").strip() + if raw == "[DONE]": + continue + payload = json.loads(raw) + failed |= payload.get("type") == "error" + await batch.add(payload) + await batch.flush() + finally: + producer.cancel() + await asyncio.gather(producer, return_exceptions=True) + await finish(run_id, "failed" if failed else "completed", token=token) + except asyncio.CancelledError: + run = await ChatRun.get_or_none(id=run_id) + if run is None: + return + status = "cancelled" if run.cancel_requested else "interrupted" + await finish( + run_id, + status, + { + "type": "error", + "message": ( + "Generation stopped." + if status == "cancelled" + else "Generation interrupted. Please retry." + ), + }, + token=token, + tail=batch.unsent_content(), + ) + except Exception: + logging.exception("Chat generation failed") + await finish( + run_id, + "failed", + { + "type": "error", + "message": "Generation failed or exceeded its time limit. Please retry.", + }, + token=token, + ) + finally: + heartbeat.cancel() + await asyncio.gather(heartbeat, return_exceptions=True) + + +async def replay_response(run, cursor): + async def events(): + nonlocal cursor + started = time.monotonic() + last_expiry = 0.0 + while time.monotonic() - started < MAX_SECONDS + LEASE_SECONDS: + waiter = _waiter(run.id) # taken before reading, so no wakeup is lost + if time.monotonic() - last_expiry >= POLL_SECONDS: + await expire_runs(str(run.conversation_id)) + last_expiry = time.monotonic() + batch = ( + await ChatRunEvent.filter(run_id=run.id, sequence__gt=cursor) + .order_by("sequence") + .limit(200) + ) + for event in batch: + cursor = event.sequence + if event.payload.get("type") == "done": + yield f"id: {cursor}\ndata: [DONE]\n\n" + return + yield f"id: {cursor}\ndata: {json.dumps(event.payload)}\n\n" + latest = await ChatRun.get_or_none(id=run.id) + if latest is None or ( + latest.status != "running" and cursor >= latest.sequence + ): + yield "data: [DONE]\n\n" + return + if not batch: + yield ": keepalive\n\n" + try: + await asyncio.wait_for(waiter.wait(), POLL_SECONDS) + except TimeoutError: + pass + # Finite subscriber lifetime; client reattaches with its last cursor. + + response = await make_response( + events(), + 200, + { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "X-Chat-Run-ID": str(run.id), + }, + ) + response.timeout = None + return response + + +def serialize(run): + return { + "id": str(run.id), + "conversation_id": str(run.conversation_id), + "status": run.status, + "cursor": run.sequence, + "created_at": run.created_at.isoformat(), + "query": run.payload.get("query", ""), + "image_key": run.payload.get("image_key"), + } + + +@runs_blueprint.post("/runs") +@jwt_access_required +async def post_run(): + try: + run, created = await create_run( + current_app._get_current_object(), + get_jwt_identity(), + await request.get_json(), + ) + except RunConflict as exc: + return jsonify({"error": str(exc)}), exc.status + return jsonify(serialize(run)), 201 if created else 200 + + +@runs_blueprint.get("//runs") +@jwt_access_required +async def discover(conversation_id): + owner = get_jwt_identity() + try: + conversation = await Conversation.get_or_none(id=conversation_id, user_id=owner) + except ValueError: + conversation = None + if conversation is None: + return jsonify({"error": "conversation not found"}), 404 + await expire_runs(conversation_id) + # Latest terminal run included so restart/interruption can be shown after refresh. + run = ( + await ChatRun.filter(conversation_id=conversation.id, owner_id=owner) + .order_by("-created_at") + .first() + ) + return jsonify({"run": serialize(run) if run else None}) + + +@runs_blueprint.get("//runs//events") +@jwt_access_required +async def get_events(conversation_id, run_id): + run = await owned_run(get_jwt_identity(), conversation_id, run_id) + if run is None: + return jsonify({"error": "run not found"}), 404 + try: + cursor = int( + request.args.get("cursor", request.headers.get("Last-Event-ID", "0")) + ) + if cursor < 0 or cursor > run.sequence: + raise ValueError() + except ValueError: + return jsonify({"error": "invalid cursor"}), 400 + return await replay_response(run, cursor) + + +@runs_blueprint.post("//runs//cancel") +@jwt_access_required +async def cancel_run(conversation_id, run_id): + run = await owned_run(get_jwt_identity(), conversation_id, run_id) + if run is None: + return jsonify({"error": "run not found"}), 404 + await ChatRun.filter(id=run.id, status="running").update(cancel_requested=True) + return jsonify( + {"status": "cancel_requested" if run.status == "running" else run.status} + ) + + +def begin_drain(app): + """Call when ingress/health starts draining, before awaiting run completion.""" + _draining_apps.add(id(app)) + + +def is_draining(app): + return id(app) in _draining_apps + + +async def drain_runs(app, timeout=None): + """Root calls BEFORE closing agents/checkpointers/database, after stopping ingress.""" + begin_drain(app) + claimed_ids = { + run_id for run_id, (task_app, _) in list(_tasks.items()) if task_app is app + } + tasks = [task for task_app, task in list(_tasks.values()) if task_app is app] + if tasks: + _, pending = await asyncio.wait( + tasks, + timeout=( + timeout + if timeout is not None + else float(os.getenv("CHAT_RUN_DRAIN_SECONDS", "1800")) + ), + ) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + # Covers a task cancelled before its coroutine first ran. + async with app.app_context(): + for run in await ChatRun.filter(id__in=claimed_ids, status="running"): + await finish( + run.id, + "interrupted", + { + "type": "error", + "message": "Generation interrupted during shutdown. Please retry.", + }, + ) diff --git a/blueprints/conversation/runtime.py b/blueprints/conversation/runtime.py new file mode 100644 index 0000000..740293c --- /dev/null +++ b/blueprints/conversation/runtime.py @@ -0,0 +1,171 @@ +"""Shared agent invocation for authenticated chat and messaging channels.""" + +import asyncio +import json +import logging +import time +import uuid +import weakref +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from langchain_core.messages import convert_to_messages +from quart import current_app + +from metrics import AGENT_REQUESTS_TOTAL, AGENT_RESPONSE_SECONDS +from .logic import get_agent_for +from .memory import build_system_prompt_with_memories + + +# Concurrent turns on one checkpoint thread race on read-modify-write of its +# history, silently dropping a turn. Hold this around preparation + invocation. +_thread_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = ( + weakref.WeakValueDictionary() +) + + +@asynccontextmanager +async def thread_lock(thread_id: str): + lock = _thread_locks.get(thread_id) + if lock is None: + lock = _thread_locks[thread_id] = asyncio.Lock() + async with lock: + yield + + +@dataclass +class PreparedTurn: + agent: object + payload: dict + config: dict + + +async def prepare_summary(agent, payload: dict, config: dict) -> None: + store = getattr(current_app, "conversation_summary_store", None) + if store is None: + return + configurable = config["configurable"] + try: + checkpoint = await agent.aget_state(config) + history = convert_to_messages(checkpoint.values.get("messages", [])) + state = await store.update( + configurable["user_id"], + configurable["thread_id"], + history + convert_to_messages(payload["messages"]), + ) + configurable["conversation_summary"] = state.as_message() + except Exception: + logging.exception("Failed to prepare conversation summary") + + +async def prepare_agent_turn( + user, + query: str, + thread_id: str, + *, + channel: str = "web", + messages: list | None = None, + instructions: str = "", +) -> PreparedTurn: + """Callers resolve and authorize the user/thread before preparing a turn. + + Only authenticated server-side user objects determine permissions. Caller + input cannot supply a config that overrides identity or administration. + """ + if user is None or not getattr(user, "id", None): + raise ValueError("An authenticated user is required for agent invocation") + if not isinstance(query, str) or not isinstance(thread_id, str) or not thread_id: + raise ValueError("query and thread_id must be valid strings") + prompt = await build_system_prompt_with_memories(str(user.id), query=query) + if instructions: + prompt += "\n\n" + instructions + config = { + "configurable": { + "user_id": str(user.id), + "is_admin": bool(user.is_admin()), + "thread_id": thread_id, + "system_prompt": prompt, + "channel": channel, + "request_id": str(uuid.uuid4()), + } + } + payload = { + "messages": list(messages) + if messages is not None + else [{"role": "user", "content": query}] + } + agent = get_agent_for(user) + await prepare_summary(agent, payload, config) + return PreparedTurn(agent, payload, config) + + +def _record(turn: PreparedTurn, started: float, result: str) -> None: + cfg = turn.config["configurable"] + seconds = time.monotonic() - started + AGENT_REQUESTS_TOTAL.labels(channel=cfg["channel"], result=result).inc() + AGENT_RESPONSE_SECONDS.labels(channel=cfg["channel"]).observe(seconds) + logging.info( + json.dumps( + { + "event": "agent_invocation", + "request_id": cfg["request_id"], + "channel": cfg["channel"], + "thread_id": cfg["thread_id"], + "user_id": cfg["user_id"], + "result": result, + "total_ms": round(seconds * 1000), + } + ) + ) + + +async def invoke_prepared(turn: PreparedTurn) -> dict: + started = time.monotonic() + result = "success" + try: + return await turn.agent.ainvoke(turn.payload, config=turn.config) + except asyncio.CancelledError: + result = "cancelled" + raise + except Exception: + result = "error" + raise + finally: + _record(turn, started, result) + + +async def invoke_agent_for_user( + user, + query: str, + thread_id: str, + *, + channel: str = "web", + messages: list | None = None, + instructions: str = "", +) -> dict: + async with thread_lock(thread_id): + turn = await prepare_agent_turn( + user, + query, + thread_id, + channel=channel, + messages=messages, + instructions=instructions, + ) + return await invoke_prepared(turn) + + +async def stream_prepared(turn: PreparedTurn): + started = time.monotonic() + result = "cancelled" + try: + async for event in turn.agent.astream_events( + turn.payload, version="v2", config=turn.config + ): + yield event + result = "success" + except Exception: + result = "error" + raise + finally: + _record(turn, started, result) diff --git a/blueprints/cron/__init__.py b/blueprints/cron/__init__.py index cb2432e..af49b9b 100644 --- a/blueprints/cron/__init__.py +++ b/blueprints/cron/__init__.py @@ -1,9 +1,9 @@ import hmac import logging import os -from datetime import datetime, timezone -from quart import Blueprint, current_app, jsonify, request +from blueprints.scheduled_messages.validation import json_object +from quart import Blueprint, jsonify, request from blueprints.users.decorators import admin_required from blueprints.scheduled_messages.models import MessageChannel @@ -23,6 +23,7 @@ def _serialize(job: CronJob) -> dict: "recipient": job.recipient, "channel": job.channel.value, "enabled": job.enabled, + "created_by": str(job.created_by_id) if job.created_by_id else None, "last_run_at": job.last_run_at.isoformat() if job.last_run_at else None, "last_status": job.last_status, "last_error": job.last_error, @@ -49,9 +50,12 @@ async def list_jobs(): @cron_blueprint.route("/", methods=["POST"]) @admin_required async def create_job(): - data = await request.get_json() - if not data: - return jsonify({"error": "Invalid payload"}), 400 + data, error = await json_object( + ("name", "prompt", "recipient", "channel", "description", "created_by"), + nullable=("description", "created_by"), + ) + if error: + return error name = (data.get("name") or "").strip() prompt = (data.get("prompt") or "").strip() @@ -71,7 +75,10 @@ async def create_job(): {"error": f"Invalid channel: {channel_str}. Must be 'imessage' or 'email'"} ), 400 + from quart_jwt_extended import get_jwt_identity + job = await CronJob.create( + created_by_id=get_jwt_identity(), name=name, description=description, prompt=prompt, @@ -89,10 +96,30 @@ async def update_job(job_id: str): if not job: return jsonify({"error": "Not found"}), 404 - data = await request.get_json() - if not data: - return jsonify({"error": "Invalid payload"}), 400 + data, error = await json_object( + ("name", "prompt", "recipient", "channel", "description", "created_by"), + nullable=("description", "created_by"), + ) + if error: + return error + blank = [ + f for f in ("name", "prompt", "recipient") if f in data and not data[f].strip() + ] + if blank: + return jsonify({"error": f"{', '.join(blank)} cannot be empty"}), 400 + + if "created_by" in data: + from blueprints.users.models import User + + owner = ( + await User.get_or_none(id=data["created_by"]) + if data["created_by"] + else None + ) + if not owner: + return jsonify({"error": "created_by must identify an existing user"}), 400 + job.created_by_id = owner.id if "name" in data: job.name = data["name"].strip() if "description" in data: @@ -133,44 +160,49 @@ async def trigger_job(job_id: str): if not job: return jsonify({"error": "Not found"}), 404 - if not job.enabled: - return jsonify({"skipped": True}), 200 + data = await request.get_json(silent=True) + if not isinstance(data, dict): + data = {} + run_id = request.headers.get("Idempotency-Key") or data.get("run_id") + if not isinstance(run_id, str) or not run_id.strip() or len(run_id) > 255: + return jsonify( + {"error": "run_id or Idempotency-Key is required (maximum 255 characters)"} + ), 400 + from .scheduler import execute_job - try: - import uuid + result = await execute_job(job, run_id) + return jsonify(result), 200 if result.get("ok") or result.get("skipped") else 409 - response = await current_app.main_agent.ainvoke( - {"messages": [{"role": "user", "content": job.prompt}]}, - config={"configurable": {"thread_id": f"cron-{uuid.uuid4()}"}}, - ) - result = response.get("messages", [])[-1].content - if job.channel == MessageChannel.IMESSAGE: - from blueprints.imessage import send_imessage - from utils.strip_markdown import strip_markdown +@cron_blueprint.route("//deliveries", methods=["GET"]) +@admin_required +async def delivery_history(job_id): + if not await CronJob.filter(id=job_id).exists(): + return jsonify({"error": "Not found"}), 404 + from blueprints.scheduled_messages.delivery import history - await send_imessage(job.recipient, strip_markdown(result)) + return jsonify(await history("cron", job_id)) - elif job.channel == MessageChannel.EMAIL: - from blueprints.email import send_email_reply - await send_email_reply( - to=job.recipient, - subject=job.name, - body=result, - ) +@cron_blueprint.route("//retry", methods=["POST"]) +@admin_required +async def retry_job(job_id): + job = await CronJob.get_or_none(id=job_id) + if not job: + return jsonify({"error": "Not found"}), 404 + data = await request.get_json(silent=True) + if not isinstance(data, dict): + data = {} + run_id = data.get("run_id") + if not isinstance(run_id, str) or not run_id or len(run_id) > 255: + return jsonify({"error": "run_id is required"}), 400 + from blueprints.scheduled_messages.models import JobDelivery - job.last_run_at = datetime.now(timezone.utc) - job.last_status = "success" - job.last_error = None - await job.save() - logger.info(f"Cron job {job.id} ({job.name}) executed successfully") - return jsonify({"ok": True}) + if not await JobDelivery.filter( + kind="cron", job_id=str(job.id), run_id=run_id, status="failed" + ).exists(): + return jsonify({"error": "Only safe failed deliveries can be retried"}), 409 + from .scheduler import execute_job - except Exception as e: - job.last_run_at = datetime.now(timezone.utc) - job.last_status = "failed" - job.last_error = str(e) - await job.save() - logger.error(f"Cron job {job.id} ({job.name}) failed: {e}") - return jsonify({"error": str(e)}), 500 + result = await execute_job(job, run_id, retry=True) + return jsonify(result), 200 if result.get("ok") or result.get("skipped") else 409 diff --git a/blueprints/cron/models.py b/blueprints/cron/models.py index 7d376f7..f9f347e 100644 --- a/blueprints/cron/models.py +++ b/blueprints/cron/models.py @@ -6,6 +6,9 @@ from blueprints.scheduled_messages.models import MessageChannel class CronJob(Model): id = fields.UUIDField(primary_key=True) + created_by = fields.ForeignKeyField( + "models.User", null=True, related_name="cron_jobs", on_delete=fields.SET_NULL + ) name = fields.CharField(max_length=255) description = fields.TextField(null=True) prompt = fields.TextField() diff --git a/blueprints/cron/scheduler.py b/blueprints/cron/scheduler.py new file mode 100644 index 0000000..a1f2537 --- /dev/null +++ b/blueprints/cron/scheduler.py @@ -0,0 +1,59 @@ +"""One durable delivery per caller-supplied cron run identifier.""" + +from tortoise.transactions import in_transaction + +from blueprints.scheduled_messages import delivery +from .models import CronJob + + +async def execute_job(job, run_id, *, retry=False): + await delivery.expire_claims("cron") + if not job.enabled: + return {"skipped": True, "status": "disabled"} + claimed = await delivery.claim("cron", job.id, run_id, retry=retry) + if not claimed: + from blueprints.scheduled_messages.models import JobDelivery + + existing = await JobDelivery.get(kind="cron", job_id=str(job.id), run_id=run_id) + return {"skipped": True, "status": existing.status} + sending = False + try: + if not job.created_by_id: + raise ValueError( + "Cron job requires an explicit created_by owner before execution" + ) + user = await job.created_by + if user is None: + raise ValueError("Cron job owner no longer exists") + content = await delivery.agent_content( + user, job.prompt, f"cron-{claimed.id}", "cron" + ) + async with in_transaction() as db: + active = ( + await CronJob.filter(id=job.id, enabled=True) + .using_db(db) + .update(updated_at=delivery.now()) + ) + if not active: + await delivery.transition(claimed, "cancelled") + return {"skipped": True, "status": "cancelled"} + if not await delivery.transition(claimed, "sending"): + return {"skipped": True, "status": "uncertain"} + sending = True + await delivery.send(job.channel, job.recipient, content, job.name) + await delivery.transition(claimed, "sent") + status, error = "sent", None + except Exception as exc: + status = ( + "uncertain" + if sending and not isinstance(exc, delivery.SafeDeliveryError) + else "failed" + ) + error = str(exc) + await delivery.transition(claimed, status, error) + await CronJob.filter(id=job.id).update( + last_run_at=delivery.now(), + last_status="success" if status == "sent" else status, + last_error=error, + ) + return {"ok": status == "sent", "status": status, "error": error} diff --git a/blueprints/email/__init__.py b/blueprints/email/__init__.py index 229dcd3..8071963 100644 --- a/blueprints/email/__init__.py +++ b/blueprints/email/__init__.py @@ -7,7 +7,7 @@ import time from collections import defaultdict import httpx -from quart import Blueprint, current_app, request +from quart import Blueprint, request from blueprints.users.models import User from blueprints.conversation.logic import ( @@ -15,8 +15,8 @@ from blueprints.conversation.logic import ( add_message_to_conversation, persist_agent_tool_messages, ) -from blueprints.conversation.models import Speaker -from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT +from blueprints.conversation.runtime import invoke_agent_for_user +from utils.strip_markdown import extract_agent_text from . import models # noqa: F401 — register Tortoise ORM models from .helpers import generate_email_token, get_user_email_address # noqa: F401 @@ -193,18 +193,14 @@ async def webhook(): # Build messages payload try: - messages = await conversation.messages.all() - recent_messages = list(messages)[-20:] - - messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}] - for msg in recent_messages[:-1]: - role = "user" if msg.speaker == Speaker.USER else "assistant" - messages_payload.append({"role": role, "content": msg.text}) - messages_payload.append({"role": "user", "content": body}) - - logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages") - response = await current_app.main_agent.ainvoke({"messages": messages_payload}) - response_text = response.get("messages", [])[-1].content + response = await invoke_agent_for_user( + user, + body, + str(conversation.id), + channel="email", + instructions="You are replying by email. Write a clear reply appropriate to that channel.", + ) + response_text = extract_agent_text(response.get("messages", [])[-1].content) await persist_agent_tool_messages( conversation, user, response.get("messages", []) ) diff --git a/blueprints/email/helpers.py b/blueprints/email/helpers.py index d094579..a5f4568 100644 --- a/blueprints/email/helpers.py +++ b/blueprints/email/helpers.py @@ -4,9 +4,9 @@ import hashlib def generate_email_token(user_id: str, secret: str) -> str: """Generate a 16-char hex HMAC token for a user's email address.""" - return hmac.new( - secret.encode(), str(user_id).encode(), hashlib.sha256 - ).hexdigest()[:16] + return hmac.new(secret.encode(), str(user_id).encode(), hashlib.sha256).hexdigest()[ + :16 + ] def get_user_email_address(token: str, domain: str) -> str: diff --git a/blueprints/imessage/__init__.py b/blueprints/imessage/__init__.py index 1caa398..09bb029 100644 --- a/blueprints/imessage/__init__.py +++ b/blueprints/imessage/__init__.py @@ -11,13 +11,12 @@ from quart import Blueprint, request, jsonify from blueprints.users.models import User from blueprints.conversation.logic import ( - get_agent_for, get_conversation_for_channel, add_message_to_conversation, persist_agent_tool_messages, ) from blueprints.conversation.models import Conversation, ConversationMessage -from blueprints.conversation.memory import build_system_prompt_with_memories +from blueprints.conversation.runtime import invoke_agent_for_user from utils.strip_markdown import extract_agent_text, strip_markdown imessage_blueprint = Blueprint("imessage_api", __name__, url_prefix="/api/imessage") @@ -344,24 +343,13 @@ async def _process_and_reply( else: user_content = content - system_prompt = await build_system_prompt_with_memories(str(user.id)) - system_prompt += ( - "\n\nIMPORTANT: You are replying via iMessage. Use plain text only. " - "Do not use markdown tables, headers, bullet points, bold, italics, " - "or any other markdown formatting. Write in natural prose." - ) - agent_config = { - "configurable": { - "user_id": str(user.id), - "is_admin": user.is_admin(), - "thread_id": str(conversation.id), - "system_prompt": system_prompt, - } - } - logger.info(f"Invoking LangChain agent for conversation {conversation.id}") - response = await get_agent_for(user).ainvoke( - {"messages": [{"role": "user", "content": user_content}]}, - config=agent_config, + response = await invoke_agent_for_user( + user, + content or "", + str(conversation.id), + channel="imessage", + messages=[{"role": "user", "content": user_content}], + instructions="You are replying via iMessage. Use plain text and natural prose; avoid markdown formatting.", ) response_text = extract_agent_text(response.get("messages", [])[-1].content) await persist_agent_tool_messages( diff --git a/blueprints/integrations/__init__.py b/blueprints/integrations/__init__.py new file mode 100644 index 0000000..b189419 --- /dev/null +++ b/blueprints/integrations/__init__.py @@ -0,0 +1,116 @@ +"""Authenticated, secret-free status of configured integrations and cached data.""" + +import asyncio +import os +from datetime import datetime, timezone + +from quart import Blueprint, jsonify +from quart_jwt_extended import get_jwt_identity + +from blueprints.users.decorators import jwt_access_required +from blueprints.users.models import User +from blueprints.ynab.sync import _current_state, ynab_configured + +integrations_blueprint = Blueprint( + "integrations_api", __name__, url_prefix="/api/integrations" +) + + +def configuration_status(name: str, configured: bool) -> dict: + return { + "name": name, + "status": "configured" if configured else "not_configured", + "last_success_at": None, + "message": "Configured; connection has not been checked." + if configured + else "Not configured.", + } + + +async def _ynab_status() -> dict: + status = configuration_status("YNAB", ynab_configured()) + if not ynab_configured(): + return status + try: + state = await _current_state() + if state is None or state.last_synced_at is None: + return { + **status, + "status": "unavailable", + "message": "No synchronized budget data is available.", + } + stamp = state.last_synced_at + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=timezone.utc) + age = max(0, (datetime.now(timezone.utc) - stamp).total_seconds()) + stale = age > 120 or bool(state.last_error) + return { + **status, + "status": "stale" if stale else "ready", + "last_success_at": stamp.isoformat(), + "message": "Using cached budget data; synchronization is overdue or failed." + if stale + else "Budget data is current.", + } + except Exception: + return { + **status, + "status": "unavailable", + "message": "Budget synchronization status is unavailable.", + } + + +async def integration_statuses(*, is_admin: bool) -> list[dict]: + result = [await _ynab_status()] + notes = configuration_status("Document search", bool(os.getenv("DATABASE_URL"))) + try: + from blueprints.rag.logic import get_vector_store_stats + + stats = await asyncio.to_thread(get_vector_store_stats) + count = stats.get("total_documents", 0) + notes.update( + status="ready" if count else "unavailable", + indexed_chunks=count, + message=f"{count} indexed document chunks available." + if count + else "No documents are indexed yet.", + ) + except Exception: + notes.update( + status="unavailable", message="Document index status is unavailable." + ) + result.append(notes) + for name, keys in [ + ("Mealie", ("MEALIE_BASE_URL", "MEALIE_API_TOKEN")), + ("Obsidian", ("OBSIDIAN_VAULT_PATH",)), + ("Web search", ("TAVILY_API_KEY",)), + ("Local model", ("LLAMA_SERVER_URL",)), + ("Fallback model", ("OPENAI_API_KEY",)), + ("SimbaKit", ("SIMBAKIT_URL",)), + ]: + result.append(configuration_status(name, all(os.getenv(k) for k in keys))) + if is_admin: + for name, keys in [ + ("Homebox", ("HOMEBOX_URL", "HOMEBOX_TOKEN")), + ( + "BookStack", + ("BOOKSTACK_BASE_URL", "BOOKSTACK_TOKEN_ID", "BOOKSTACK_TOKEN_SECRET"), + ), + ("Google", ("GOOGLE_TOKEN_FILE",)), + ("iMessage", ("SENDBLUE_API_KEY", "SENDBLUE_API_SECRET")), + ("Email", ("MAILGUN_API_KEY", "MAILGUN_DOMAIN")), + ]: + result.append(configuration_status(name, all(os.getenv(k) for k in keys))) + return result + + +@integrations_blueprint.get("/") +@jwt_access_required +async def status(): + user = await User.get(id=get_jwt_identity()) + return jsonify( + { + "checked_at": datetime.now(timezone.utc).isoformat(), + "integrations": await integration_statuses(is_admin=user.is_admin()), + } + ) diff --git a/blueprints/rag/logic.py b/blueprints/rag/logic.py index ee05aa1..227b7f8 100644 --- a/blueprints/rag/logic.py +++ b/blueprints/rag/logic.py @@ -16,6 +16,7 @@ from langchain_text_splitters import ( from sqlalchemy import create_engine, text from .fetchers import PaperlessNGXService +from .retrieval import build_filter, citation_url, document_dates from utils.obsidian_service import ObsidianService # Load environment variables @@ -142,6 +143,7 @@ def date_to_epoch(date_str: str) -> float: 0, 0, 0, + tzinfo=datetime.timezone.utc, ) return date.timestamp() @@ -155,6 +157,9 @@ async def fetch_documents_from_paperless_ngx() -> list[Document]: for doc in data: metadata = { "source": "paperless", + "document_id": doc.get("id"), + "document_date": doc["created_date"][:10], + "indexed_at": time.time(), "created_date": date_to_epoch(doc["created_date"]), "filename": doc["original_file_name"], "document_type": doctypes.get(doc["document_type"], ""), @@ -422,6 +427,8 @@ async def query_vector_store( query: str, source: str | None = None, k: int = 8, + start_date: str | None = None, + end_date: str | None = None, ): """Query the vector store with optional source filtering and MMR. @@ -430,16 +437,35 @@ async def query_vector_store( source: Filter by source metadata (e.g., "obsidian", "paperless") k: Number of results to return """ + if not isinstance(query, str) or not query.strip(): + raise ValueError("query must not be empty") + if not isinstance(k, int) or not 1 <= k <= 20: + raise ValueError("k must be between 1 and 20") + filter_dict = build_filter(source, start_date, end_date) vector_store = _get_vector_store() - filter_dict = {"source": source} if source else None retrieved_docs = await vector_store.amax_marginal_relevance_search( query, k=k, fetch_k=k * 3, filter=filter_dict, ) + if start_date or end_date: + retrieved_docs = [ + doc + for doc in retrieved_docs + # Same "any date field in range" rule as the database filter. + if any( + (not start_date or stamp >= start_date) + and (not end_date or stamp <= end_date) + for stamp in document_dates(doc.metadata) + ) + ] serialized = "\n\n".join( - (f"Source: {doc.metadata}\nContent: {doc.page_content}") + ( + f"Source: {doc.metadata}\n" + + (f"Citation: {citation_url(doc)}\n" if citation_url(doc) else "") + + f"Content: {doc.page_content}" + ) for doc in retrieved_docs ) return serialized, retrieved_docs diff --git a/blueprints/rag/retrieval.py b/blueprints/rag/retrieval.py new file mode 100644 index 0000000..b47071c --- /dev/null +++ b/blueprints/rag/retrieval.py @@ -0,0 +1,105 @@ +"""Validated retrieval filters and stable, authenticated source references.""" + +from datetime import date, datetime, time, timedelta, timezone +import re +from uuid import UUID + + +def parse_date(value: str | None) -> date | None: + if not value: + return None + try: + if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + raise ValueError + return date.fromisoformat(value) + except (TypeError, ValueError) as exc: + raise ValueError("Dates must be YYYY-MM-DD.") from exc + + +def build_filter( + source: str | None, start_date: str | None = None, end_date: str | None = None +) -> dict | None: + if source not in {None, "paperless", "obsidian"}: + raise ValueError("source must be paperless or obsidian") + start, end = parse_date(start_date), parse_date(end_date) + if start and end and start > end: + raise ValueError("start_date must be on or before end_date") + parts = [{"source": source}] if source else [] + if start or end: + strings, epochs = {}, {} + if start: + strings["$gte"] = start.isoformat() + epochs["$gte"] = datetime.combine(start, time.min, timezone.utc).timestamp() + if end: + try: + next_day = end + timedelta(days=1) + except OverflowError as exc: + raise ValueError("end_date is beyond the supported date range") from exc + strings["$lt"] = next_day.isoformat() + epochs["$lt"] = datetime.combine( + next_day, time.min, timezone.utc + ).timestamp() + + # Both newly normalized fields and existing indexed metadata remain usable. + def date_bounds(field, bounds): + clauses = [{field: {operator: value}} for operator, value in bounds.items()] + return {"$and": clauses} if len(clauses) > 1 else clauses[0] + + parts.append( + { + "$or": [ + date_bounds("document_date", strings), + { + "$and": [ + {"source": "paperless"}, + date_bounds("created_date", epochs), + ] + }, + {"$and": [{"source": "obsidian"}, date_bounds("date", strings)]}, + { + "$and": [ + {"source": "obsidian"}, + date_bounds("created_at", strings), + ] + }, + ] + } + ) + return {"$and": parts} if len(parts) > 1 else parts[0] if parts else None + + +def document_dates(metadata: dict) -> list[str]: + """Every date the search filter can match on, as YYYY-MM-DD.""" + dates = [] + for key in ("document_date", "date", "created_at"): + value = metadata.get(key) + if isinstance(value, (date, datetime)): + dates.append(value.isoformat()[:10]) + elif isinstance(value, str): + try: + dates.append(date.fromisoformat(value[:10]).isoformat()) + except ValueError: + pass + try: + if metadata.get("created_date") is not None: + dates.append( + datetime.fromtimestamp(float(metadata["created_date"]), timezone.utc) + .date() + .isoformat() + ) + except (ValueError, TypeError, OverflowError, OSError): + pass + return dates + + +def document_date(metadata: dict) -> str | None: + """The date shown for a citation: the most specific one available.""" + dates = document_dates(metadata) + return dates[0] if dates else None + + +def citation_url(document) -> str | None: + try: + return "/sources/" + str(UUID(str(document.id))) + except (ValueError, TypeError, AttributeError): + return None diff --git a/blueprints/rag/source_api.py b/blueprints/rag/source_api.py new file mode 100644 index 0000000..12e7239 --- /dev/null +++ b/blueprints/rag/source_api.py @@ -0,0 +1,86 @@ +"""Read-only source chunks from the same authenticated shared retrieval corpus.""" + +import asyncio +import os +from urllib.parse import urlsplit +from uuid import UUID + +from quart import Blueprint, jsonify +from sqlalchemy import text + +from blueprints.conversation.artifacts_util import ( + obsidian_source_ref, + vault_relative_path, +) +from blueprints.users.decorators import jwt_access_required +from .retrieval import document_date + +sources_blueprint = Blueprint("sources_api", __name__, url_prefix="/api/sources") + + +def _load_chunk(identifier: str) -> dict | None: + from .logic import _get_collection_id, _get_engine + + collection = _get_collection_id() + if not collection: + return None + with _get_engine().connect() as conn: + row = conn.execute( + text( + "SELECT document, cmetadata FROM langchain_pg_embedding WHERE id=:id AND collection_id=:collection" + ), + {"id": identifier, "collection": collection}, + ).first() + if row is None: + return None + metadata = row[1] or {} + source = metadata.get("source") + if source not in {"obsidian", "paperless"}: + return None + result = { + "id": identifier, + "source": source, + "content": row[0], + "date": document_date(metadata), + "headings": [metadata[k] for k in ("h1", "h2", "h3") if metadata.get(k)], + } + if source == "obsidian": + path = vault_relative_path(metadata.get("filepath") or "") + result.update(title=metadata.get("title") or path or "Note", path=path) + result["original_url"] = obsidian_source_ref(path).get("url") if path else None + else: + result.update( + title=metadata.get("filename") or "Document", + document_type=metadata.get("document_type"), + ) + base = os.getenv("BASE_URL", "").rstrip("/") + base = base if "://" in base else "http://" + base if base else "" + parsed = urlsplit(base) + document_id = metadata.get("document_id") + result["original_url"] = ( + f"{base}/documents/{document_id}/details" + if parsed.scheme in {"http", "https"} + and parsed.hostname + and not parsed.username + and str(document_id).isdigit() + else None + ) + return result + + +@sources_blueprint.get("/") +@jwt_access_required +async def get_source(source_id): + try: + identifier = str(UUID(source_id)) + except ValueError: + return jsonify(error="source not found"), 404 + try: + result = await asyncio.to_thread(_load_chunk, identifier) + except Exception: + return jsonify(error="Source storage is unavailable. Please try again."), 503 + if result is None: + return jsonify(error="source not found"), 404 + response = jsonify(result) + response.headers["Cache-Control"] = "no-store" + return response diff --git a/blueprints/scheduled_messages/__init__.py b/blueprints/scheduled_messages/__init__.py index 3c4fd78..dacdc6d 100644 --- a/blueprints/scheduled_messages/__init__.py +++ b/blueprints/scheduled_messages/__init__.py @@ -3,7 +3,9 @@ import logging import os from datetime import datetime, timezone +from .validation import json_object from quart import Blueprint, request, jsonify +from tortoise.expressions import Q from blueprints.users.decorators import admin_required from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence @@ -18,6 +20,8 @@ logger = logging.getLogger(__name__) def _serialize(msg: ScheduledMessage) -> dict: return { "id": str(msg.id), + "series_id": str(msg.series_id or msg.id), + "retryable": msg.status == MessageStatus.FAILED, "recipient": msg.recipient, "channel": msg.channel.value, "content": msg.content, @@ -35,16 +39,29 @@ def _serialize(msg: ScheduledMessage) -> dict: @scheduled_messages_blueprint.route("/", methods=["GET"]) @admin_required async def list_messages(): - messages = await ScheduledMessage.all().order_by("-scheduled_at") + messages = await ScheduledMessage.filter(deleted_at__isnull=True).order_by( + "-scheduled_at" + ) return jsonify([_serialize(m) for m in messages]) @scheduled_messages_blueprint.route("/", methods=["POST"]) @admin_required async def create_message(): - data = await request.get_json() - if not data: - return jsonify({"error": "Invalid payload"}), 400 + data, error = await json_object( + ( + "recipient", + "channel", + "content", + "subject", + "scheduled_at", + "recurrence", + "status", + ), + nullable=("subject",), + ) + if error: + return error recipient = (data.get("recipient") or "").strip() channel = data.get("channel") @@ -112,19 +129,109 @@ async def create_message(): return jsonify(_serialize(msg)), 201 +async def _cancel(msg): + """Cancel an occurrence and stop its series without rewriting delivery outcomes. + + Rows whose delivery has started sending keep their status (sent, uncertain or + in flight); only work that has not reached a provider is cancelled. + """ + from tortoise.transactions import in_transaction + + from .models import JobDelivery + + async with in_transaction() as db: + series = msg.series_id or msg.id + # Same lock order as delivery: series row first, so the "sending" + # transition cannot interleave with this check. + await ( + ScheduledMessage.filter(id=series) + .using_db(db) + .update(updated_at=datetime.now(timezone.utc)) + ) + members = await ScheduledMessage.filter( + Q(id=msg.id) | Q(series_id=series), + status__in=[ + MessageStatus.PENDING, + MessageStatus.FAILED, + MessageStatus.PROCESSING, + ], + ).using_db(db) + started = set( + await JobDelivery.filter( + kind="scheduled", + job_id__in=[str(m.id) for m in members], + status__in=["sending", "sent", "uncertain"], + ) + .using_db(db) + .values_list("job_id", flat=True) + ) + cancellable = [m.id for m in members if str(m.id) not in started] + if cancellable: + await ( + ScheduledMessage.filter(id__in=cancellable) + .using_db(db) + .update(status=MessageStatus.CANCELLED, series_id=series) + ) + # Claiming sets series_id to the row's own id, so only a different series + # id (or a predecessor) marks a one-off row as part of a series. + recurring = ( + msg.recurrence != Recurrence.NONE + or msg.previous_occurrence_id + or (msg.series_id and msg.series_id != msg.id) + ) + if msg.id not in cancellable and not recurring: + return jsonify( + {"error": "Delivery already started; it cannot be cancelled"} + ), 409 + if recurring and not cancellable: + # Nothing pending to mark: a hidden tombstone stops future occurrences. + await ScheduledMessage.create( + series_id=series, + recipient=msg.recipient, + channel=msg.channel, + content=msg.content, + subject=msg.subject, + scheduled_at=msg.scheduled_at, + recurrence=Recurrence.NONE, + status=MessageStatus.CANCELLED, + deleted_at=datetime.now(timezone.utc), + created_by_id=msg.created_by_id, + using_db=db, + ) + await msg.refresh_from_db() + return jsonify(_serialize(msg)) + + @scheduled_messages_blueprint.route("/", methods=["PUT"]) @admin_required async def update_message(msg_id: str): - msg = await ScheduledMessage.get_or_none(id=msg_id) + msg = await ScheduledMessage.get_or_none(id=msg_id, deleted_at__isnull=True) if not msg: return jsonify({"error": "Not found"}), 404 + data, error = await json_object( + ( + "recipient", + "channel", + "content", + "subject", + "scheduled_at", + "recurrence", + "status", + ), + nullable=("subject",), + ) + if error: + return error + + if data.get("status") == "cancelled": + return await _cancel(msg) if msg.status not in (MessageStatus.PENDING, MessageStatus.FAILED): return jsonify({"error": "Can only update pending or failed messages"}), 400 - data = await request.get_json() - if not data: - return jsonify({"error": "Invalid payload"}), 400 + blank = [f for f in ("recipient", "content") if f in data and not data[f].strip()] + if blank: + return jsonify({"error": f"{', '.join(blank)} cannot be empty"}), 400 rescheduled = False if "recipient" in data: @@ -137,7 +244,7 @@ async def update_message(msg_id: str): if "content" in data: msg.content = data["content"].strip() if "subject" in data: - msg.subject = data["subject"].strip() or None + msg.subject = (data["subject"] or "").strip() or None if "recurrence" in data: try: msg.recurrence = Recurrence(data["recurrence"]) @@ -156,16 +263,31 @@ async def update_message(msg_id: str): rescheduled = True except ValueError: return jsonify({"error": "Invalid scheduled_at format"}), 400 - if "status" in data and data["status"] == "cancelled": - msg.status = MessageStatus.CANCELLED - elif rescheduled: - msg.status = MessageStatus.PENDING + if rescheduled and msg.status == MessageStatus.PENDING: msg.error_message = None if msg.channel == MessageChannel.EMAIL and not msg.subject: return jsonify({"error": "subject is required for email messages"}), 400 - await msg.save() + values = { + field: getattr(msg, field) + for field in ( + "recipient", + "channel", + "content", + "subject", + "scheduled_at", + "recurrence", + "use_agent", + "status", + "error_message", + ) + } + changed = await ScheduledMessage.filter( + id=msg.id, status__in=[MessageStatus.PENDING, MessageStatus.FAILED] + ).update(**values) + if not changed: + return jsonify({"error": "Message was claimed; update rejected"}), 409 if rescheduled and msg.status == MessageStatus.PENDING: from utils.cronicle import create_message_event @@ -197,12 +319,80 @@ async def trigger_due_messages(): @scheduled_messages_blueprint.route("/", methods=["DELETE"]) @admin_required async def delete_message(msg_id: str): - msg = await ScheduledMessage.get_or_none(id=msg_id) + msg = await ScheduledMessage.get_or_none(id=msg_id, deleted_at__isnull=True) if not msg: return jsonify({"error": "Not found"}), 404 if msg.status not in (MessageStatus.PENDING, MessageStatus.CANCELLED): return jsonify({"error": "Can only delete pending or cancelled messages"}), 400 - await msg.delete() + if msg.recurrence != Recurrence.NONE or msg.series_id or msg.previous_occurrence_id: + # Keep a durable cancellation tombstone so recurrence repair cannot revive + # it, even if this occurrence was since edited to be non-recurring. + from tortoise.transactions import in_transaction + + async with in_transaction() as db: + series = msg.series_id or msg.id + await ( + ScheduledMessage.filter(id=series) + .using_db(db) + .update(updated_at=datetime.now(timezone.utc)) + ) + deleted = ( + await ScheduledMessage.filter( + id=msg.id, + status__in=[MessageStatus.PENDING, MessageStatus.CANCELLED], + ) + .using_db(db) + .update( + status=MessageStatus.CANCELLED, + series_id=series, + deleted_at=datetime.now(timezone.utc), + ) + ) + if not deleted: + return jsonify({"error": "Message was claimed; deletion rejected"}), 409 + await ( + ScheduledMessage.filter( + series_id=series, + status__in=[ + MessageStatus.PENDING, + MessageStatus.FAILED, + MessageStatus.PROCESSING, + ], + ) + .using_db(db) + .update(status=MessageStatus.CANCELLED) + ) + else: + deleted = await ScheduledMessage.filter( + id=msg.id, status__in=[MessageStatus.PENDING, MessageStatus.CANCELLED] + ).delete() + if not deleted: + return jsonify({"error": "Message was claimed; deletion rejected"}), 409 return jsonify({"status": "deleted"}) + + +@scheduled_messages_blueprint.route("//deliveries", methods=["GET"]) +@admin_required +async def delivery_history(msg_id): + if not await ScheduledMessage.filter(id=msg_id, deleted_at__isnull=True).exists(): + return jsonify({"error": "Not found"}), 404 + from .delivery import history + + return jsonify(await history("scheduled", msg_id)) + + +@scheduled_messages_blueprint.route("//retry", methods=["POST"]) +@admin_required +async def retry_message(msg_id): + msg = await ScheduledMessage.get_or_none(id=msg_id, deleted_at__isnull=True) + if not msg: + return jsonify({"error": "Not found"}), 404 + if msg.status != MessageStatus.FAILED: + return jsonify({"error": "Only safe failed deliveries can be retried"}), 409 + from .scheduler import process_message + + status = await process_message(msg, retry=True) + await msg.refresh_from_db() + return jsonify(_serialize(msg)), 200 if status == "sent" else 409 diff --git a/blueprints/scheduled_messages/delivery.py b/blueprints/scheduled_messages/delivery.py new file mode 100644 index 0000000..b1a9dda --- /dev/null +++ b/blueprints/scheduled_messages/delivery.py @@ -0,0 +1,171 @@ +"""DB compare-and-set claims shared by scheduled and cron delivery. + +The sending marker is committed BEFORE calling the provider. Any provider +exception is ambiguous unless it explicitly guarantees no external effect. +""" + +import os +import uuid +from datetime import datetime, timedelta, timezone + +import httpx +from tortoise.transactions import in_transaction + +from .models import JobAttempt, JobDelivery + + +class SafeDeliveryError(Exception): + """Provider rejected the request before any delivery could occur.""" + + +def now(): + return datetime.now(timezone.utc) + + +async def claim(kind, job_id, run_id, *, retry=False): + delivery, _ = await JobDelivery.get_or_create( + kind=kind, job_id=str(job_id), run_id=run_id + ) + token = uuid.uuid4() + async with in_transaction() as db: + won = ( + await JobDelivery.filter( + id=delivery.id, status="failed" if retry else "pending" + ) + .using_db(db) + .update( + status="processing", claim_token=token, claimed_at=now(), error=None + ) + ) + if not won: + return None + await JobAttempt.create(delivery_id=delivery.id, claim_token=token, using_db=db) + return await JobDelivery.get(id=delivery.id) + + +async def transition(delivery, status, error=None): + async with in_transaction() as db: + won = ( + await JobDelivery.filter( + id=delivery.id, + claim_token=delivery.claim_token, + status__in=["processing", "sending"], + ) + .using_db(db) + .update(status=status, error=error, updated_at=now()) + ) + if won: + await ( + JobAttempt.filter(claim_token=delivery.claim_token) + .using_db(db) + .update( + status=status, + error=error, + finished_at=None if status == "sending" else now(), + ) + ) + return bool(won) + + +async def expire_claims(kind, *, age=timedelta(hours=1)): + """Abandoned work requires reconciliation, never an automatic resend.""" + stale = await JobDelivery.filter( + kind=kind, status__in=["processing", "sending"], claimed_at__lt=now() - age + ) + for delivery in stale: + await transition( + delivery, "uncertain", "Worker abandoned; delivery requires reconciliation" + ) + + +async def agent_content(user, prompt, thread_id, channel): + from blueprints.conversation.runtime import invoke_agent_for_user + from utils.strip_markdown import extract_agent_text + + response = await invoke_agent_for_user(user, prompt, thread_id, channel=channel) + messages = response.get("messages", []) + if not messages: + raise ValueError("Agent returned no messages") + last = messages[-1] + content = last.get("content") if isinstance(last, dict) else last.content + content = extract_agent_text(content) + if not content: + raise ValueError("Agent returned no text") + return content + + +async def send(channel, recipient, content, subject=None): + from .models import MessageChannel + + try: + if channel == MessageChannel.IMESSAGE: + from blueprints.imessage import send_imessage + from utils.strip_markdown import strip_markdown + + await send_imessage(recipient, strip_markdown(content)) + elif channel == MessageChannel.EMAIL: + # The shared reply helper logs provider failures and returns None. + # Delivery tracking requires an explicit provider acknowledgement. + api_key = os.getenv("MAILGUN_API_KEY") + domain = os.getenv("MAILGUN_DOMAIN") + if not api_key or not domain: + raise SafeDeliveryError( + "MAILGUN_API_KEY or MAILGUN_DOMAIN not configured" + ) + subject = subject or "(no subject)" + async with httpx.AsyncClient() as client: + response = await client.post( + f"https://api.mailgun.net/v3/{domain}/messages", + auth=("api", api_key), + data={ + "from": f"Simba ", + "to": recipient, + "subject": subject + if subject.startswith("Re:") + else f"Re: {subject}", + "text": content, + }, + timeout=30, + ) + response.raise_for_status() + else: + raise SafeDeliveryError("Unsupported channel") + except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc: + raise SafeDeliveryError(str(exc)) from exc + except httpx.HTTPStatusError as exc: + # Only explicit request rejection is safe. Server errors/timeouts may + # follow an accepted delivery and must remain uncertain. + if exc.response.status_code in {400, 401, 403, 404, 405, 413, 422, 429}: + raise SafeDeliveryError(str(exc)) from exc + raise + + +async def history(kind, job_id): + deliveries = await JobDelivery.filter(kind=kind, job_id=str(job_id)).order_by( + "-created_at" + ) + result = [] + for d in deliveries: + attempts = await JobAttempt.filter(delivery_id=d.id).order_by("started_at") + result.append( + { + "id": str(d.id), + "run_id": d.run_id, + "status": d.status, + "error": d.error, + "retryable": d.status == "failed", + "attempts": [ + { + "id": str(a.id), + "status": a.status, + "error": a.error, + "started_at": a.started_at.isoformat(), + "finished_at": a.finished_at.isoformat() + if a.finished_at + else None, + } + for a in attempts + ], + } + ) + return result diff --git a/blueprints/scheduled_messages/models.py b/blueprints/scheduled_messages/models.py index 0be6081..cb2f32d 100644 --- a/blueprints/scheduled_messages/models.py +++ b/blueprints/scheduled_messages/models.py @@ -11,6 +11,8 @@ class MessageChannel(enum.Enum): class MessageStatus(enum.Enum): PENDING = "pending" + PROCESSING = "processing" + UNCERTAIN = "uncertain" SENT = "sent" FAILED = "failed" CANCELLED = "cancelled" @@ -25,6 +27,14 @@ class Recurrence(enum.Enum): class ScheduledMessage(Model): id = fields.UUIDField(primary_key=True) + deleted_at = fields.DatetimeField(null=True) + series_id = fields.UUIDField(null=True) + previous_occurrence = fields.OneToOneField( + "models.ScheduledMessage", + null=True, + related_name="next_occurrence", + on_delete=fields.SET_NULL, + ) recipient = fields.CharField(max_length=255) channel = fields.CharEnumField(enum_type=MessageChannel, max_length=20) content = fields.TextField() @@ -46,3 +56,35 @@ class ScheduledMessage(Model): class Meta: table = "scheduled_messages" + + +class JobDelivery(Model): + """Durable run identity; never reclaim ambiguous external deliveries.""" + + id = fields.UUIDField(primary_key=True) + kind = fields.CharField(max_length=20) + job_id = fields.CharField(max_length=36) + run_id = fields.CharField(max_length=255) + status = fields.CharField(max_length=20, default="pending") + claim_token = fields.UUIDField(null=True) + claimed_at = fields.DatetimeField(null=True) + error = fields.TextField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "job_deliveries" + unique_together = (("kind", "job_id", "run_id"),) + + +class JobAttempt(Model): + id = fields.UUIDField(primary_key=True) + delivery = fields.ForeignKeyField("models.JobDelivery", related_name="attempts") + claim_token = fields.UUIDField(unique=True) + status = fields.CharField(max_length=20, default="processing") + error = fields.TextField(null=True) + started_at = fields.DatetimeField(auto_now_add=True) + finished_at = fields.DatetimeField(null=True) + + class Meta: + table = "job_attempts" diff --git a/blueprints/scheduled_messages/scheduler.py b/blueprints/scheduled_messages/scheduler.py index 7b61c78..21fa163 100644 --- a/blueprints/scheduled_messages/scheduler.py +++ b/blueprints/scheduled_messages/scheduler.py @@ -1,13 +1,13 @@ import logging -import uuid from datetime import datetime, timezone -from dateutil.relativedelta import relativedelta -from quart import current_app -from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence +from dateutil.relativedelta import relativedelta +from tortoise.transactions import in_transaction + +from . import delivery as transport +from .models import ScheduledMessage, MessageStatus, Recurrence, JobDelivery logger = logging.getLogger(__name__) - RECURRENCE_DELTAS = { Recurrence.DAILY: relativedelta(days=1), Recurrence.WEEKLY: relativedelta(weeks=1), @@ -15,88 +15,178 @@ RECURRENCE_DELTAS = { } -async def _run_agent(prompt: str, thread_id: str) -> str: - response = await current_app.main_agent.ainvoke( - {"messages": [{"role": "user", "content": prompt}]}, - config={"configurable": {"thread_id": thread_id}}, - ) - return response.get("messages", [])[-1].content +def run_id(msg): + # Each recurring occurrence has its own row; edits keep the same identity. + return str(msg.id) -async def _schedule_next_occurrence(msg: ScheduledMessage): +async def _schedule_next_occurrence(msg, db=None): + if db is None: + async with in_transaction() as connection: + return await _schedule_next_occurrence(msg, connection) delta = RECURRENCE_DELTAS.get(msg.recurrence) if not delta: return - + series = msg.series_id or msg.id + # Serialize series creation/cancellation on the original occurrence. + await ( + ScheduledMessage.filter(id=series) + .using_db(db) + .update(updated_at=transport.now()) + ) + if ( + await ScheduledMessage.filter(series_id=series, status=MessageStatus.CANCELLED) + .using_db(db) + .exists() + ): + return next_at = msg.scheduled_at + delta now = datetime.now(timezone.utc) while next_at <= now: next_at += delta + await ScheduledMessage.get_or_create( + previous_occurrence_id=msg.id, + defaults=dict( + series_id=series, + recipient=msg.recipient, + channel=msg.channel, + content=msg.content, + subject=msg.subject, + scheduled_at=next_at, + recurrence=msg.recurrence, + use_agent=msg.use_agent, + created_by_id=msg.created_by_id, + ), + using_db=db, + ) - await ScheduledMessage.create( - recipient=msg.recipient, - channel=msg.channel, - content=msg.content, - subject=msg.subject, - scheduled_at=next_at, - recurrence=msg.recurrence, - use_agent=msg.use_agent, - created_by_id=msg.created_by_id, - ) - logger.info( - f"Scheduled next {msg.recurrence.value} occurrence for {msg.id} at {next_at.isoformat()}" - ) + +async def process_message(msg, *, retry=False): + async with in_transaction() as db: + # Always lock the series before an occurrence, matching cancellation. + await ( + ScheduledMessage.filter(id=msg.series_id or msg.id) + .using_db(db) + .update(updated_at=transport.now()) + ) + eligible = ScheduledMessage.filter( + id=msg.id, status=MessageStatus.FAILED if retry else MessageStatus.PENDING + ) + if not retry: + eligible = eligible.filter(scheduled_at__lte=transport.now()) + won = await eligible.using_db(db).update( + status=MessageStatus.PROCESSING, series_id=msg.series_id or msg.id + ) + if not won: + return "skipped" + msg = await ScheduledMessage.get(id=msg.id).using_db(db) + claimed = await transport.claim("scheduled", msg.id, run_id(msg), retry=retry) + if not claimed: + # Preserve the authoritative delivery state if the job was rescheduled + # onto an occurrence already delivered. + d = await JobDelivery.get( + kind="scheduled", job_id=str(msg.id), run_id=run_id(msg) + ) + status = ( + MessageStatus(d.status) + if d.status in {s.value for s in MessageStatus} + else MessageStatus.UNCERTAIN + ) + await ScheduledMessage.filter(id=msg.id).using_db(db).update(status=status) + return "skipped" + sending = False + try: + content = msg.content + if msg.use_agent: + user = await msg.created_by + content = await transport.agent_content( + user, content, f"scheduled-{claimed.id}", "scheduled" + ) + # A cancellation which wins before this transaction prevents sending. + async with in_transaction() as db: + await ( + ScheduledMessage.filter(id=msg.series_id or msg.id) + .using_db(db) + .update(updated_at=transport.now()) + ) + active = ( + await ScheduledMessage.filter( + id=msg.id, status=MessageStatus.PROCESSING + ) + .using_db(db) + .update(updated_at=transport.now()) + ) + if not active: + await transport.transition(claimed, "cancelled") + return "cancelled" + if not await transport.transition(claimed, "sending"): + return "skipped" + sending = True + await transport.send(msg.channel, msg.recipient, content, msg.subject) + async with in_transaction() as db: + await ( + ScheduledMessage.filter(id=msg.series_id or msg.id) + .using_db(db) + .update(updated_at=transport.now()) + ) + await transport.transition(claimed, "sent") + active = ( + await ScheduledMessage.filter( + id=msg.id, status=MessageStatus.PROCESSING + ) + .using_db(db) + .update(status=MessageStatus.SENT, error_message=None) + ) + if active: + await _schedule_next_occurrence(msg, db) + return "sent" + except Exception as exc: + status = ( + "uncertain" + if sending and not isinstance(exc, transport.SafeDeliveryError) + else "failed" + ) + await transport.transition(claimed, status, str(exc)) + await ScheduledMessage.filter( + id=msg.id, status=MessageStatus.PROCESSING + ).update(status=MessageStatus(status), error_message=str(exc)) + logger.exception("Scheduled delivery %s ended as %s", msg.id, status) + return status async def process_due_messages() -> dict: - """Process all currently due pending messages. Returns a result summary.""" - now = datetime.now(timezone.utc) - due = await ScheduledMessage.filter( - status=MessageStatus.PENDING, - scheduled_at__lte=now, - ).all() - - sent, failed = 0, 0 - for msg in due: - try: - send_content = msg.content - - if msg.use_agent: - send_content = await _run_agent( - msg.content, f"scheduled-{uuid.uuid4()}" - ) - - if msg.channel == MessageChannel.IMESSAGE: - from blueprints.imessage import send_imessage - from utils.strip_markdown import strip_markdown - - await send_imessage(msg.recipient, strip_markdown(send_content)) - - elif msg.channel == MessageChannel.EMAIL: - from blueprints.email import send_email_reply - - await send_email_reply( - to=msg.recipient, - subject=msg.subject or "(no subject)", - body=send_content, - ) - - msg.status = MessageStatus.SENT - msg.error_message = None - await msg.save() - logger.info( - f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}" - ) - sent += 1 - - if msg.recurrence != Recurrence.NONE: + await transport.expire_claims("scheduled") + uncertain = await JobDelivery.filter(kind="scheduled", status="uncertain") + for d in uncertain: + await ScheduledMessage.filter( + id=d.job_id, status=MessageStatus.PROCESSING + ).update(status=MessageStatus.UNCERTAIN, error_message=d.error) + # Recover recurrence creation if an earlier successful send was committed. + # Only rows sent through delivery claims qualify: rows sent before migration + # 20 have no successor links, and repairing them would fork each historical + # occurrence into its own new series. + orphans = await ScheduledMessage.filter( + status=MessageStatus.SENT, + deleted_at__isnull=True, + next_occurrence__id__isnull=True, + ).exclude(recurrence=Recurrence.NONE) + if orphans: + claimed_sends = set( + await JobDelivery.filter( + kind="scheduled", + status="sent", + job_id__in=[str(m.id) for m in orphans], + ).values_list("job_id", flat=True) + ) + for msg in orphans: + if str(msg.id) in claimed_sends: await _schedule_next_occurrence(msg) - - except Exception as e: - msg.status = MessageStatus.FAILED - msg.error_message = str(e) - await msg.save() - logger.error(f"Failed to send scheduled message {msg.id}: {e}") - failed += 1 - - return {"sent": sent, "failed": failed} + due = await ScheduledMessage.filter( + status=MessageStatus.PENDING, scheduled_at__lte=transport.now() + ) + counts = {"sent": 0, "failed": 0, "uncertain": 0} + for msg in due: + result = await process_message(msg) + if result in counts: + counts[result] += 1 + return counts diff --git a/blueprints/scheduled_messages/validation.py b/blueprints/scheduled_messages/validation.py new file mode 100644 index 0000000..291b981 --- /dev/null +++ b/blueprints/scheduled_messages/validation.py @@ -0,0 +1,25 @@ +"""Request-body checks shared by the scheduled message and cron endpoints.""" + +from quart import jsonify, request + + +async def json_object(text_fields: tuple[str, ...], nullable: tuple[str, ...] = ()): + """Return (data, None) for a non-empty object whose text fields are strings. + + Fields in `nullable` may also be an explicit null. Otherwise return + (None, error_response) so malformed bodies get a 400 instead of failing on + `.get`/`.strip` with a 500. + """ + data = await request.get_json(silent=True) + if not isinstance(data, dict) or not data: + return None, (jsonify({"error": "Invalid payload"}), 400) + wrong = [ + name + for name in text_fields + if name in data + and not isinstance(data[name], str) + and not (data[name] is None and name in nullable) + ] + if wrong: + return None, (jsonify({"error": f"{', '.join(wrong)} must be text"}), 400) + return data, None diff --git a/blueprints/whatsapp/__init__.py b/blueprints/whatsapp/__init__.py index 30bd214..ed1c35d 100644 --- a/blueprints/whatsapp/__init__.py +++ b/blueprints/whatsapp/__init__.py @@ -9,11 +9,11 @@ from twilio.twiml.messaging_response import MessagingResponse from blueprints.users.models import User from blueprints.conversation.logic import ( - get_agent_for, get_conversation_for_channel, add_message_to_conversation, persist_agent_tool_messages, ) +from blueprints.conversation.runtime import invoke_agent_for_user from utils.strip_markdown import extract_agent_text whatsapp_blueprint = Blueprint("whatsapp_api", __name__, url_prefix="/api/whatsapp") @@ -177,17 +177,11 @@ async def webhook(): ) try: - agent_config = { - "configurable": { - "user_id": str(user.id), - "is_admin": user.is_admin(), - "thread_id": str(conversation.id), - } - } - logger.info(f"Invoking LangChain agent for conversation {conversation.id}") - response = await get_agent_for(user).ainvoke( - {"messages": [{"role": "user", "content": body}]}, - config=agent_config, + response = await invoke_agent_for_user( + user, + body, + str(conversation.id), + channel="whatsapp", ) response_text = extract_agent_text(response.get("messages", [])[-1].content) await persist_agent_tool_messages( diff --git a/blueprints/ynab/sync.py b/blueprints/ynab/sync.py index b73e36c..8f39072 100644 --- a/blueprints/ynab/sync.py +++ b/blueprints/ynab/sync.py @@ -14,6 +14,7 @@ import asyncio import logging import os import time +from contextvars import ContextVar from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from decimal import Decimal @@ -46,6 +47,9 @@ FAILURE_BACKOFF_SECONDS = 60 LOOKUP_CHUNK = 2000 _sync_lock = asyncio.Lock() +tool_data_freshness: ContextVar[dict | None] = ContextVar( + "ynab_tool_data_freshness", default=None +) _last_failure = float("-inf") @@ -616,6 +620,7 @@ async def ensure_fresh(max_age_seconds: int = 120) -> YnabSyncState | None: ``FAILURE_BACKOFF_SECONDS``. Returns None only if the mirror has never been populated. """ + tool_data_freshness.set(None) state = await _current_state() if not _is_fresh(state, max_age_seconds) and not _backing_off(): try: @@ -625,6 +630,14 @@ async def ensure_fresh(max_age_seconds: int = 120) -> YnabSyncState | None: state = await _current_state() if state is None or state.server_knowledge is None: return None + stamp = _norm(state.last_synced_at) if state.last_synced_at else None + tool_data_freshness.set( + { + "system": "ynab", + "as_of": stamp.isoformat() if stamp else None, + "stale": not _is_fresh(state, max_age_seconds), + } + ) return state diff --git a/blueprints/ynab/tools.py b/blueprints/ynab/tools.py index 2719360..c8fc1ae 100644 --- a/blueprints/ynab/tools.py +++ b/blueprints/ynab/tools.py @@ -21,7 +21,7 @@ from blueprints.conversation.artifacts_util import ( make_artifact, ) -from .sync import ensure_fresh +from .sync import ensure_fresh, tool_data_freshness logger = logging.getLogger(__name__) @@ -64,8 +64,32 @@ def ynab_tool_errors(fn): @functools.wraps(fn) async def wrapper(*args, **kwargs): + tool_data_freshness.set(None) try: - return await fn(*args, **kwargs) + result = await fn(*args, **kwargs) + freshness = tool_data_freshness.get() + if not freshness: + return result + warning = ( + ( + f"\nData last synchronized: {freshness['as_of'] or 'unknown'}. " + "WARNING: cached budget data is stale; disclose this when answering." + ) + if freshness["stale"] + else "" + ) + if isinstance(result, tuple) and len(result) == 2: + content, artifact = result + if isinstance(artifact, dict): + artifact = { + **artifact, + "source_ref": { + **(artifact.get("source_ref") or {}), + **freshness, + }, + } + return str(content) + warning, artifact + return str(result) + warning except Exception as e: logger.exception(f"{fn.__name__} failed") return f"YNAB error in {fn.__name__}: {type(e).__name__}: {e}" diff --git a/config/db.py b/config/db.py index 2e1a26a..e5b9831 100644 --- a/config/db.py +++ b/config/db.py @@ -14,6 +14,7 @@ TORTOISE_CONFIG = { "models": { "models": [ "blueprints.conversation.models", + "blueprints.conversation.run_models", "blueprints.users.models", "blueprints.email.models", "blueprints.scheduled_messages.models", diff --git a/docker-compose.yml b/docker-compose.yml index 562d2b6..18afa6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,9 @@ x-raggr-common: &raggr-common - RESPONSE_RESERVE_TOKENS=${RESPONSE_RESERVE_TOKENS:-4096} - CHECKPOINT_DB_PATH=${CHECKPOINT_DB_PATH:-/app/checkpoints/checkpoints.db} - CONVERSATION_SUMMARY_DB_PATH=${CONVERSATION_SUMMARY_DB_PATH:-${CHECKPOINT_DB_PATH:-/app/checkpoints/checkpoints.db}.summaries.db} + - CHAT_RUN_LEASE_SECONDS=${CHAT_RUN_LEASE_SECONDS:-30} + - CHAT_RUN_MAX_SECONDS=${CHAT_RUN_MAX_SECONDS:-1800} + - CHAT_RUN_DRAIN_SECONDS=${CHAT_RUN_DRAIN_SECONDS:-1800} - OBSIDIAN_SYNC_INTERVAL=${OBSIDIAN_SYNC_INTERVAL:-60} - OIDC_AUTHORIZATION_ENDPOINT=${OIDC_AUTHORIZATION_ENDPOINT:-} - OIDC_TOKEN_ENDPOINT=${OIDC_TOKEN_ENDPOINT:-} diff --git a/docs/chat-runs.md b/docs/chat-runs.md new file mode 100644 index 0000000..7eab23b --- /dev/null +++ b/docs/chat-runs.md @@ -0,0 +1,54 @@ +# Reconnectable chat runs + +A generation belongs to a durable, authenticated run rather than an HTTP connection. Closing a tab, navigating away, or losing the network detaches its subscriber. Explicit Stop sends cancellation to the run. Generation calls the authoritative `runtime.prepare_agent_turn` and `runtime.stream_prepared` under its own application context, without retaining the request context. + +## Wiring + +- `blueprints.conversation.run_models` is registered in `config/db.py`; the tables come from migration `20_20261012231017_application_foundation.py`. +- `app.py` registers `runs_blueprint`, calls `expire_runs()` after ORM start-up (only already-expired leases; another deployment may still own live runs) and `drain_runs(app)` right after the serving lifecycle's `yield`, while the agent and checkpointer are still open. +- `python app.py` serves through Hypercorn with `shutdown_timeout = CHAT_RUN_DRAIN_SECONDS + 30`, so lifespan shutdown outlasts the drain. `scripts/deploy.sh` gives the old container `CHAT_RUN_DRAIN_SECONDS + 60` (override with `DEPLOY_STOP_SECONDS`). +- Every writer to a conversation checkpoint is serialized: `/runs` and `/stream-query` through `ChatRun.active_key` (cross-process), `/query` returns 409 while a run is active, and all channels share `runtime.thread_lock` (in-process). +- Streamed deltas are coalesced (`FLUSH_SECONDS`, 150 ms) before they are written, and same-process subscribers are woken on each write; other processes fall back to polling every `POLL_SECONDS`. + +## API + +All endpoints require the existing JWT authentication. Run creation, discovery, replay and cancellation check both owner and conversation. Attachment keys must be under the authorized conversation's storage prefix. + +- `POST /api/conversation/runs`: `{conversation_id, query, request_id, image_key?, file_key?}`. Returns a run (`201` new, `200` idempotent replay). Clients must retain the same request ID for ambiguous retries. A reused ID with different input returns 409; another active run in the conversation returns a clear 409 before saving another user message or starting generation. Legacy callers may omit request ID and receive a fresh one. +- `GET /api/conversation/{conversation_id}/runs`: `{run: ... | null}`. Returns the latest run, including terminal status, so loading a conversation can discover active generation or an interrupted attempt. Includes original query and image key for the brief interval before the user message is saved. +- `GET /api/conversation/{conversation_id}/runs/{run_id}/events?cursor=N`: ordered SSE replay followed by live database polling. Only events with sequence greater than N are returned. `Last-Event-ID` is also supported when no query cursor is supplied. Negative, noninteger and ahead-of-log cursors return 400. Event `id:` is the durable sequence; event payload types remain `thinking`, `thinking_delta`, `tool_start`, `tool_end`, `content`, `response`, `error`, and terminal `[DONE]`. Comment heartbeats do not advance the cursor. +- `POST /api/conversation/{conversation_id}/runs/{run_id}/cancel`: requests cancellation; it is idempotent. The database flag reaches the owning process through its heartbeat, normally within one second. Completion may win a race with cancellation. Keep reading until the terminal event. +- `POST /api/conversation/stream-query`: compatibility adapter creating a run and attaching at cursor zero. Existing event types and `[DONE]` remain; `X-Chat-Run-ID` identifies the run. A disconnect detaches this endpoint too. + +Run statuses are `running`, `completed`, `failed`, `cancelled`, and `interrupted`. Terminal errors precede `[DONE]`. A model stall retains its existing `stalled: true` event and metrics. Transient subscriber failures are recoverable and are distinct from generation failure. + +## Storage and lifecycle + +`chat_runs` stores the authenticated owner, input, idempotency key, claim token, lease, cancellation flag, partial text and persistence markers. Its nullable unique `active_key` holds a conversation until terminalization. A composite uniqueness constraint makes duplicate creation safe across processes. `chat_run_events` has unique `(run_id, sequence)` ordering. Database transactions lock the run before appending events, finalizing or saving messages; answer and user persistence markers are committed with their messages. Tool messages/artifacts and the existing streaming metrics/timeouts stay on the established streaming path. + +PostgreSQL is the deployment store; real file-backed SQLite exercises the same ORM lifecycle in tests. Subscribers can land on either deployment process because replay reads shared storage. The owning process renews its lease, checks cross-process cancellation, and cancels itself if renewal fails. Expired runs save available partial text once, release admission and emit an interrupted error. A process restart **does not restart generation**. Prior persisted events and partial answers remain available; the user starts a new turn explicitly. Lease expiry assumes failed renewal promptly stops the worker; it is not fencing for independent checkpoint writers. + +Defaults, read from environment at module load: + +| Setting | Default | Purpose | +| --- | --- | --- | +| `CHAT_RUN_LEASE_SECONDS` | 30 | Abandoned-worker lease; minimum 10 seconds | +| `CHAT_RUN_MAX_SECONDS` | 1800 | Entire generation, including preparation and tools | +| `CHAT_RUN_DRAIN_SECONDS` | 1800 | Wait for tracked generations before interrupting on shutdown | +| `DEPLOY_STOP_SECONDS` | drain + 60 | Deployment container stop grace | +| `TTFT_TIMEOUT_SECONDS` | existing 30 | Existing per-model silent-output timeout | + +Subscribers have finite lifetimes. The browser retries with its last successfully handled cursor, bounds retries after consecutive transport failures, and bounds silent reads. A fresh conversation load rebuilds the active turn from cursor zero after excluding saved tool/assistant rows from that turn, avoiding double-rendered output. Unmount and route switching abort only the reader. Stop uses the cancellation endpoint, including when pressed during creation. + +## Validation + +```sh +.venv/bin/python -m pytest tests/unit/test_chat_runs.py tests/unit/test_agent_runtime.py -q +cd raggr-frontend +node --test tests/runs*.test.cjs +npx tsc --noEmit +``` + +Tests cover real SQLite creation/claim contention, owner/conversation isolation and JWT endpoints, replay order/cursor, once-only answer persistence, request idempotency, application context, disconnect versus cancellation, file database close/reopen and lease expiry, drain, the actual shared runtime and legacy endpoint, SSE framing/duplicate suppression, authenticated discovery/cancel and cursor retry. + +The stall/metrics tests run through the durable worker: persistence happens in `runs.py`, and request cancellation detaches the subscriber rather than stopping generation. diff --git a/docs/job-delivery.md b/docs/job-delivery.md new file mode 100644 index 0000000..c2431b5 --- /dev/null +++ b/docs/job-delivery.md @@ -0,0 +1,103 @@ +# Scheduled and cron job delivery + +Priority2 uses durable delivery claims and attempt history. Apply migration 20 +(`migrations/models/20_20261012231017_application_foundation.py`) before running the +updated backend. It adds `job_deliveries` and `job_attempts`, unique delivery +identity `(kind, job_id, run_id)`, claim tokens/timestamps, scheduled-message +`series_id`, unique `previous_occurrence_id`, `deleted_at`, and nullable cron +`created_by_id`. Migration 20 does not assign owners to existing cron jobs. +Downgrading drops delivery history and ownership/series fields. + +## Scheduled messages + +The panel preserves creation, cancellation and deletion. It polls the message +list every ten seconds and exposes list/action errors, including permission or +claim conflicts. Delivery history is loaded on demand with authenticated +`GET /api/scheduled-messages//deliveries`. Refresh history to see later changes; +a retry refreshes both the list and any open history, even when it fails. +History includes each delivery's run ID, status/error, and every attempt's status, +error, start time and finish time. An unfinished attempt is explicitly labelled. + +| Status | Meaning / action | +| --- | --- | +| `pending` | Waiting for its scheduled time; cancel or delete is available. | +| `processing` | A worker claimed the delivery; retry is unavailable. | +| `sending` | Attempt/history status: provider call is underway. | +| `sent` | Provider acknowledged delivery; retry is unavailable. | +| `failed` | Failure known to be safe to retry; explicit retry is available only when `retryable: true`. | +| `uncertain` | Delivery might already have happened. Reconcile with provider records or the recipient before considering any resend. | +| `cancelled` | Future work cancelled; delete is available. | + +Retry uses authenticated `POST /api/scheduled-messages//retry` with no schedule +or status mutation. It executes the existing delivery identity and records a new +attempt. A retry can return HTTP 409 with a changed message status (including +`uncertain`); the UI surfaces the error and reloads the state. Editing a failed +message's scheduled time does not retry it. The frontend update service accepts +only `cancelled` as a status mutation and rejects `pending` at runtime, so updating +status cannot bypass the explicit retry endpoint. + +Uncertain outcomes include ambiguous provider failures and abandoned claims. +They are never automatically retried. This panel has no reconciliation or force +resend action. Resolve the evidence through an operational reconciliation process; +do not create a replacement message or new run ID simply to bypass uncertainty. + +Recurring occurrences have separate rows and delivery identities, linked by +`series_id` and `previous_occurrence_id`. Cancelling a recurring occurrence cancels +the series' future work. The panel labels this action **Cancel series**, including +on completed or uncertain occurrences where future work must be stopped. Already +started provider calls may still complete; cancellation cannot recall a delivered +message, and attempt history remains evidence of the outcome. +Deleting a pending/cancelled recurring occurrence uses the backend's soft delete +and durable cancellation tombstone: the selected occurrence is hidden, and future +series work is cancelled so recurrence repair cannot revive it. Other cancelled +occurrences can remain visible. Nonrecurring pending/cancelled rows use the existing +delete API. The UI never simulates cancellation/deletion by altering local series +rows; it reloads the backend result. + +## Cron callers and ownership + +`POST /api/cron//trigger` requires `X-Cron-Secret` and a nonblank run identifier, +maximum 255 characters. Supply either a JSON `run_id` or an `Idempotency-Key` header; +the header takes precedence. Choose a stable identifier for each logical execution +(for example the cron occurrence's UTC timestamp), and reuse it across repeated +trigger requests for that execution. A new logical occurrence needs a new ID. +Repeating a trigger with the same ID returns the existing outcome without resending; +it does not retry a failed or uncertain delivery. + +```http +POST /api/cron//trigger +X-Cron-Secret: +Idempotency-Key: daily-report:2026-10-13T12:00:00Z +Content-Type: application/json + +{} +``` + +Legacy cron jobs require an explicitly assigned existing user. An administrator +must use authenticated `PUT /api/cron/` with +`{"created_by":""}` after reviewing the correct owner. Execution +fails safely until ownership is assigned; it never selects an arbitrary admin. +New jobs get the authenticated creator as owner. Agent execution uses that owner's +context and permissions. Assigning ownership does not itself retry an earlier +failed execution. + +Authenticated `GET /api/cron//deliveries` returns the same history structure. +For a safe failed execution only, an administrator can use authenticated +`POST /api/cron//retry` with `{"run_id":""}`. Reuse the failed +run's ID; do not invent an ID to evade delivery claims. Processing, sending, sent, +cancelled and uncertain executions are ineligible. Uncertain cron deliveries need +the same reconciliation as scheduled messages. Database claims prevent duplicate +workers from winning the same delivery; they cannot prove exactly-once external +delivery when a provider acknowledgement is lost. + +## Frontend validation + +No additional packages are required. Tests use Node's test runner, the installed +TypeScript transpiler and React's static renderer, following the existing renderer +harness in `raggr-frontend/tests/history-management.test.cjs`. + +```sh +cd raggr-frontend +node --test tests/job-delivery*.cjs +./node_modules/.bin/tsc --noEmit --target ES2020 --lib DOM,ES2020 +``` diff --git a/metrics.py b/metrics.py index 859b923..b928209 100644 --- a/metrics.py +++ b/metrics.py @@ -9,6 +9,20 @@ from prometheus_client import ( REGISTRY = CollectorRegistry(auto_describe=True) +AGENT_REQUESTS_TOTAL = Counter( + "raggr_agent_requests_total", + "Agent generation requests by channel and outcome.", + labelnames=("channel", "result"), + registry=REGISTRY, +) +AGENT_RESPONSE_SECONDS = Histogram( + "raggr_agent_response_seconds", + "Agent generation time by channel, excluding delivery.", + labelnames=("channel",), + buckets=(0.5, 1, 2, 5, 10, 30, 60, 120, 300), + registry=REGISTRY, +) + CHAT_RESPONSE_SECONDS = Histogram( "raggr_chat_response_seconds", "End-to-end chat response time in seconds.", diff --git a/migrations/models/20_20261012231017_application_foundation.py b/migrations/models/20_20261012231017_application_foundation.py new file mode 100644 index 0000000..c18da9e --- /dev/null +++ b/migrations/models/20_20261012231017_application_foundation.py @@ -0,0 +1,218 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "chat_runs" ( + "id" UUID NOT NULL PRIMARY KEY, + "request_key" VARCHAR(128) NOT NULL, + "payload" JSONB NOT NULL, + "active_key" VARCHAR(36) UNIQUE, + "status" VARCHAR(20) NOT NULL DEFAULT 'running', + "worker_token" UUID NOT NULL, + "lease_until" TIMESTAMPTZ NOT NULL, + "cancel_requested" BOOL NOT NULL DEFAULT False, + "sequence" INT NOT NULL DEFAULT 0, + "partial" TEXT NOT NULL, + "user_saved" BOOL NOT NULL DEFAULT False, + "answer_saved" BOOL NOT NULL DEFAULT False, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "conversation_id" UUID NOT NULL REFERENCES "conversations" ("id") ON DELETE CASCADE, + "owner_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_chat_runs_owner_i_98309f" UNIQUE ("owner_id", "conversation_id", "request_key") +); +CREATE INDEX IF NOT EXISTS "idx_chat_runs_lease_u_b8a59a" ON "chat_runs" ("lease_until"); + CREATE TABLE IF NOT EXISTS "chat_run_events" ( + "id" BIGSERIAL NOT NULL PRIMARY KEY, + "sequence" INT NOT NULL, + "payload" JSONB NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "run_id" UUID NOT NULL REFERENCES "chat_runs" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_chat_run_ev_run_id_1326b1" UNIQUE ("run_id", "sequence") +); + CREATE TABLE IF NOT EXISTS "job_deliveries" ( + "id" UUID NOT NULL PRIMARY KEY, + "kind" VARCHAR(20) NOT NULL, + "job_id" VARCHAR(36) NOT NULL, + "run_id" VARCHAR(255) NOT NULL, + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "claim_token" UUID, + "claimed_at" TIMESTAMPTZ, + "error" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "uid_job_deliver_kind_1add26" UNIQUE ("kind", "job_id", "run_id") +); +COMMENT ON TABLE "job_deliveries" IS 'Durable run identity; never reclaim ambiguous external deliveries.'; + CREATE TABLE IF NOT EXISTS "job_attempts" ( + "id" UUID NOT NULL PRIMARY KEY, + "claim_token" UUID NOT NULL UNIQUE, + "status" VARCHAR(20) NOT NULL DEFAULT 'processing', + "error" TEXT, + "started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finished_at" TIMESTAMPTZ, + "delivery_id" UUID NOT NULL REFERENCES "job_deliveries" ("id") ON DELETE CASCADE +); + ALTER TABLE "conversations" ADD "pinned" BOOL NOT NULL DEFAULT False; + ALTER TABLE "scheduled_messages" ADD "deleted_at" TIMESTAMPTZ; + ALTER TABLE "scheduled_messages" ADD "previous_occurrence_id" UUID UNIQUE; + ALTER TABLE "scheduled_messages" ADD "series_id" UUID; + COMMENT ON COLUMN "scheduled_messages"."status" IS 'PENDING: pending +PROCESSING: processing +UNCERTAIN: uncertain +SENT: sent +FAILED: failed +CANCELLED: cancelled'; + ALTER TABLE "cron_jobs" ADD "created_by_id" UUID; + ALTER TABLE "scheduled_messages" ADD CONSTRAINT "fk_schedule_schedule_31982f9c" FOREIGN KEY ("previous_occurrence_id") REFERENCES "scheduled_messages" ("id") ON DELETE SET NULL; + ALTER TABLE "cron_jobs" ADD CONSTRAINT "fk_cron_job_users_b3e69ad6" FOREIGN KEY ("created_by_id") REFERENCES "users" ("id") ON DELETE SET NULL;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "scheduled_messages" DROP CONSTRAINT IF EXISTS "fk_schedule_schedule_31982f9c"; + ALTER TABLE "cron_jobs" DROP CONSTRAINT IF EXISTS "fk_cron_job_users_b3e69ad6"; + ALTER TABLE "cron_jobs" DROP COLUMN "created_by_id"; + ALTER TABLE "conversations" DROP COLUMN "pinned"; + ALTER TABLE "scheduled_messages" DROP COLUMN "deleted_at"; + ALTER TABLE "scheduled_messages" DROP COLUMN "previous_occurrence_id"; + ALTER TABLE "scheduled_messages" DROP COLUMN "series_id"; + COMMENT ON COLUMN "scheduled_messages"."status" IS 'PENDING: pending +SENT: sent +FAILED: failed +CANCELLED: cancelled'; + DROP TABLE IF EXISTS "chat_runs"; + DROP TABLE IF EXISTS "job_attempts"; + DROP TABLE IF EXISTS "chat_run_events"; + DROP TABLE IF EXISTS "job_deliveries";""" + + +MODELS_STATE = ( + "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" +) diff --git a/raggr-frontend/src/App.tsx b/raggr-frontend/src/App.tsx index 8d33ab6..80f9a83 100644 --- a/raggr-frontend/src/App.tsx +++ b/raggr-frontend/src/App.tsx @@ -4,6 +4,7 @@ import { AuthProvider } from "./contexts/AuthContext"; import { ChatScreen } from "./components/ChatScreen"; import { ConversationsPage } from "./components/ConversationsPage"; import { LoginScreen } from "./components/LoginScreen"; +import { SourceViewer } from "./components/SourceViewer"; import { useAuthCheck } from "./hooks/useAuthCheck"; import catIcon from "./assets/cat.png"; @@ -32,6 +33,7 @@ const AppContainer = () => { return ( } /> + } /> {/* One ChatScreen instance serves both the home ("/") and per-conversation ("/c/:id") routes. Using a single catch-all route keeps that instance mounted across the navigate() that fires when the first message creates diff --git a/raggr-frontend/src/api/artifactTypes.ts b/raggr-frontend/src/api/artifactTypes.ts index b3db843..eb0bc05 100644 --- a/raggr-frontend/src/api/artifactTypes.ts +++ b/raggr-frontend/src/api/artifactTypes.ts @@ -24,6 +24,8 @@ export type ArtifactSourceRef = { id?: string; path?: string; url?: string; + as_of?: string | null; + stale?: boolean; }; /** Full artifact as returned by `GET /api/artifacts/`. */ diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index d47cacb..2ae7891 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -4,288 +4,461 @@ import type { ArtifactRef } from "./artifactTypes"; export type ToolInput = Record; export type SSEEvent = - | { type: "thinking" } - | { type: "thinking_delta"; delta: string } - // v2 servers send run_id/label/input and run_id/label/status/ms/artifact; - // older servers send only `tool`, so everything else is optional here. - | { - type: "tool_start"; - tool: string; - run_id?: string; - label?: string; - input?: ToolInput; - } - | { - type: "tool_end"; - tool: string; - run_id?: string; - label?: string; - status?: "ok" | "error"; - ms?: number; - artifact?: ArtifactRef | null; - } - | { type: "content"; delta: string } - | { type: "response"; message: string } - | { type: "error"; message: string; stalled?: boolean }; + | { type: "thinking" } + | { type: "thinking_delta"; delta: string } + // v2 servers send run_id/label/input and run_id/label/status/ms/artifact; + // older servers send only `tool`, so everything else is optional here. + | { + type: "tool_start"; + tool: string; + run_id?: string; + label?: string; + input?: ToolInput; + } + | { + type: "tool_end"; + tool: string; + run_id?: string; + label?: string; + status?: "ok" | "error"; + ms?: number; + artifact?: ArtifactRef | null; + } + | { type: "content"; delta: string } + | { type: "response"; message: string } + | { type: "error"; message: string; stalled?: boolean }; export type SSEEventCallback = (event: SSEEvent) => void; +export interface ChatRun { + id: string; + conversation_id: string; + status: "running" | "completed" | "failed" | "cancelled" | "interrupted"; + cursor: number; + created_at: string; + query: string; + image_key?: string | null; +} + +export class RunRequestError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + } +} interface Message { - id: string; - text: string; - speaker: "user" | "simba" | "tool"; - created_at: string; - image_key?: string | null; - // Tool messages only (v2 servers). - tool?: string; - label?: string; - artifact?: ArtifactRef | null; + id: string; + text: string; + speaker: "user" | "simba" | "tool"; + created_at: string; + image_key?: string | null; + // Tool messages only (v2 servers). + tool?: string; + label?: string; + artifact?: ArtifactRef | null; } interface Conversation { - id: string; - name: string; - messages?: Message[]; - created_at: string; - updated_at: string; - user_id?: string; + id: string; + name: string; + messages?: Message[]; + created_at: string; + updated_at: string; + user_id?: string; } export type UploadResult = - | { kind: "image"; image_key: string } - | { kind: "file"; file_key: string; name: string; chars: number }; + | { kind: "image"; image_key: string } + | { kind: "file"; file_key: string; name: string; chars: number }; export class UploadError extends Error {} interface QueryResponse { - response: string; + response: string; } class ConversationService { - private baseUrl = "/api"; - private conversationBaseUrl = "/api/conversation"; + private baseUrl = "/api"; + private conversationBaseUrl = "/api/conversation"; - async sendQuery( - query: string, - conversation_id: string, - signal?: AbortSignal, - ): Promise { - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/query`, - { - method: "POST", - body: JSON.stringify({ query, conversation_id }), - signal, - }, - ); + async sendQuery( + query: string, + conversation_id: string, + signal?: AbortSignal, + ): Promise { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/query`, + { + method: "POST", + body: JSON.stringify({ query, conversation_id }), + signal, + }, + ); - if (!response.ok) { - throw new Error("Failed to send query"); - } + if (!response.ok) { + throw new Error("Failed to send query"); + } - return await response.json(); - } + return await response.json(); + } - async getMessages(): Promise { - const response = await userService.fetchWithAuth( - `${this.baseUrl}/messages`, - { - method: "GET", - }, - ); + async getMessages(): Promise { + const response = await userService.fetchWithAuth( + `${this.baseUrl}/messages`, + { + method: "GET", + }, + ); - if (!response.ok) { - throw new Error("Failed to fetch messages"); - } + if (!response.ok) { + throw new Error("Failed to fetch messages"); + } - return await response.json(); - } + return await response.json(); + } - async getConversation(conversationId: string): Promise { - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/${conversationId}`, - { - method: "GET", - }, - ); + async getConversation(conversationId: string): Promise { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/${conversationId}`, + { + method: "GET", + }, + ); - if (!response.ok) { - throw new Error("Failed to fetch conversation"); - } + if (!response.ok) { + throw new Error("Failed to fetch conversation"); + } - return await response.json(); - } + return await response.json(); + } - async createConversation(): Promise { - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/`, - { - method: "POST", - }, - ); + async createConversation(): Promise { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/`, + { + method: "POST", + }, + ); - if (!response.ok) { - throw new Error("Failed to create conversation"); - } + if (!response.ok) { + throw new Error("Failed to create conversation"); + } - return await response.json(); - } + return await response.json(); + } - async renameConversation( - conversationId: string, - name: string, - ): Promise { - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/${conversationId}`, - { - method: "PATCH", - body: JSON.stringify({ name }), - }, - ); + async renameConversation( + conversationId: string, + name: string, + ): Promise { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/${conversationId}`, + { + method: "PATCH", + body: JSON.stringify({ name }), + }, + ); - if (!response.ok) { - throw new Error("Failed to rename conversation"); - } + if (!response.ok) { + throw new Error("Failed to rename conversation"); + } - return await response.json(); - } + return await response.json(); + } - async deleteConversation(conversationId: string): Promise { - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/${conversationId}`, - { - method: "DELETE", - }, - ); + async deleteConversation(conversationId: string): Promise { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/${conversationId}`, + { + method: "DELETE", + }, + ); - if (!response.ok) { - throw new Error("Failed to delete conversation"); - } - } + if (!response.ok) { + throw new Error("Failed to delete conversation"); + } + } - async getAllConversations(params?: { - limit?: number; - offset?: number; - search?: string; - }): Promise { - const search = new URLSearchParams(); - if (params?.limit != null) search.set("limit", String(params.limit)); - if (params?.offset != null) search.set("offset", String(params.offset)); - if (params?.search) search.set("search", params.search); - const qs = search.toString(); + async getAllConversations(params?: { + limit?: number; + offset?: number; + search?: string; + }): Promise { + const search = new URLSearchParams(); + if (params?.limit != null) search.set("limit", String(params.limit)); + if (params?.offset != null) search.set("offset", String(params.offset)); + if (params?.search) search.set("search", params.search); + const qs = search.toString(); - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`, - { - method: "GET", - }, - ); + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`, + { + method: "GET", + }, + ); - if (!response.ok) { - throw new Error("Failed to fetch conversations"); - } + if (!response.ok) { + throw new Error("Failed to fetch conversations"); + } - return await response.json(); - } + return await response.json(); + } - async uploadAttachment( - file: File, - conversationId: string, - ): Promise { - const formData = new FormData(); - formData.append("file", file); - formData.append("conversation_id", conversationId); + async uploadAttachment( + file: File, + conversationId: string, + ): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("conversation_id", conversationId); - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/upload`, - { - method: "POST", - body: formData, - }, - { skipContentType: true }, - ); + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/upload`, + { + method: "POST", + body: formData, + }, + { skipContentType: true }, + ); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new UploadError(data.error || "Failed to upload file"); - } + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new UploadError(data.error || "Failed to upload file"); + } - return await response.json(); - } + return await response.json(); + } - async getPresignedImageUrl(imageKey: string): Promise { - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/image/${imageKey}`, - ); - if (!response.ok) { - throw new Error("Failed to get image URL"); - } - const data = await response.json(); - return data.url; - } + async getPresignedImageUrl(imageKey: string): Promise { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/image/${imageKey}`, + ); + if (!response.ok) { + throw new Error("Failed to get image URL"); + } + const data = await response.json(); + return data.url; + } - async streamQuery( - query: string, - conversation_id: string, - onEvent: SSEEventCallback, - signal?: AbortSignal, - imageKey?: string, - fileKey?: string, - ): Promise { - const body: Record = { query, conversation_id }; - if (imageKey) { - body.image_key = imageKey; - } - if (fileKey) { - body.file_key = fileKey; - } + async createRun( + body: Record, + signal?: AbortSignal, + ): Promise { + // Retry ambiguous transport failures with the SAME idempotency key. + for (let attempt = 0; ; attempt++) { + try { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/runs`, + { + method: "POST", + body: JSON.stringify(body), + signal, + }, + ); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new RunRequestError( + data.error || "Could not start generation", + response.status, + ); + } + return await response.json(); + } catch (error) { + if ( + signal?.aborted || + error instanceof RunRequestError || + attempt >= 2 || + (error instanceof Error && error.message.includes("Session expired")) + ) + throw error; + await new Promise((resolve) => + setTimeout(resolve, 500 * (attempt + 1)), + ); + } + } + } - const response = await userService.fetchWithAuth( - `${this.conversationBaseUrl}/stream-query`, - { - method: "POST", - body: JSON.stringify(body), - signal, - }, - ); + async discoverRun(conversationId: string): Promise { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/${conversationId}/runs`, + ); + if (!response.ok) throw new Error("Could not discover active generation"); + return (await response.json()).run; + } - if (!response.ok) { - throw new Error("Failed to stream query"); - } + async cancelRun(run: ChatRun): Promise { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/${run.conversation_id}/runs/${run.id}/cancel`, + { method: "POST" }, + ); + if (!response.ok) + throw new Error("Could not stop generation. Try Stop again."); + } - await this._readSSEStream(response, onEvent); - } + async resumeRun( + run: ChatRun, + onEvent: SSEEventCallback, + signal: AbortSignal, + onConnection?: (reconnecting: boolean) => void, + ): Promise { + signal = AbortSignal.any([signal, AbortSignal.timeout(35 * 60 * 1000)]); + let cursor = 0; + let failures = 0; + const deadline = Date.now() + 35 * 60 * 1000; + while (!signal.aborted && Date.now() < deadline) { + try { + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/${run.conversation_id}/runs/${run.id}/events?cursor=${cursor}`, + { signal }, + ); + if (!response.ok) + throw new RunRequestError( + "Could not resume generation", + response.status, + ); + onConnection?.(false); + const done = await this._readSSEStream( + response, + onEvent, + (id) => { + cursor = id; + failures = 0; + }, + cursor, + ); + if (done) return; + throw new Error("Connection ended before generation finished"); + } catch (error) { + if ( + signal.aborted || + error instanceof RunRequestError || + (error instanceof Error && error.message.includes("Session expired")) + ) + throw error; + onConnection?.(true); + if (++failures >= 8) + throw new Error( + "Connection lost. Reload this conversation to resume.", + ); + await new Promise((resolve, reject) => { + const aborted = () => { + clearTimeout(timer); + reject(new DOMException("Detached", "AbortError")); + }; + const timer = setTimeout( + () => { + signal.removeEventListener("abort", aborted); + resolve(); + }, + Math.min(500 * 2 ** failures, 5000), + ); + signal.addEventListener("abort", aborted, { once: true }); + }); + } + } + if (signal.aborted) throw new DOMException("Detached", "AbortError"); + throw new Error( + "Connection timed out. Reload this conversation to resume.", + ); + } - private async _readSSEStream( - response: Response, - onEvent: SSEEventCallback, - ): Promise { - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; + async streamQuery( + query: string, + conversation_id: string, + onEvent: SSEEventCallback, + signal?: AbortSignal, + imageKey?: string, + fileKey?: string, + ): Promise { + const body: Record = { query, conversation_id }; + if (imageKey) { + body.image_key = imageKey; + } + if (fileKey) { + body.file_key = fileKey; + } - while (true) { - const { done, value } = await reader.read(); - if (done) break; + const response = await userService.fetchWithAuth( + `${this.conversationBaseUrl}/stream-query`, + { + method: "POST", + body: JSON.stringify(body), + signal, + }, + ); - buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split("\n\n"); - buffer = parts.pop() ?? ""; + if (!response.ok) { + throw new Error("Failed to stream query"); + } - for (const part of parts) { - const line = part.trim(); - if (!line.startsWith("data: ")) continue; - const data = line.slice(6); - if (data === "[DONE]") return; - try { - const event = JSON.parse(data) as SSEEvent; - onEvent(event); - } catch { - // ignore malformed events - } - } - } - } + await this._readSSEStream(response, onEvent); + } + + private async _readSSEStream( + response: Response, + onEvent: SSEEventCallback, + onCursor?: (cursor: number) => void, + initialCursor = 0, + ): Promise { + if (!response.body) throw new Error("Missing event stream"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let cursor = initialCursor; + const frame = (part: string): boolean => { + const lines = part.split("\n"); + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + const rawId = lines + .find((line) => line.startsWith("id:")) + ?.slice(3) + .trim(); + const id = rawId ? Number(rawId) : undefined; + if (!data || (id !== undefined && id <= cursor)) return false; + if (data === "[DONE]") return true; + // Invalid payloads fail the connection so a durable event is never silently skipped. + const event = JSON.parse(data) as SSEEvent; + onEvent(event); + if (id !== undefined && Number.isSafeInteger(id) && id > 0) { + cursor = id; + onCursor?.(id); + } + return false; + }; + try { + while (true) { + let timer: ReturnType | undefined; + const { done, value } = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("Event stream went silent")), + 25000, + ); + }), + ]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); + if (done) return false; + buffer += decoder.decode(value, { stream: true }); + // Normalize complete CRLF pairs, including a pair split across chunks. + buffer = buffer.replace(/\r\n/g, "\n"); + let boundary: number; + while ((boundary = buffer.indexOf("\n\n")) !== -1) { + const part = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + if (frame(part)) return true; + } + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + } } export const conversationService = new ConversationService(); diff --git a/raggr-frontend/src/api/historyService.ts b/raggr-frontend/src/api/historyService.ts new file mode 100644 index 0000000..dcbd117 --- /dev/null +++ b/raggr-frontend/src/api/historyService.ts @@ -0,0 +1,56 @@ +import { userService } from "./userService"; + +export type HistoryConversation = { + id: string; + name: string; + pinned: boolean; + snippet?: string; + match_message_id?: string | null; +}; +export type SavedMemory = { id: string; content: string; updated_at: string }; + +async function request(path: string, options?: RequestInit): Promise { + const response = await userService.fetchWithAuth( + `/api/history${path}`, + options, + ); + if (!response.ok) + throw new Error("Unable to update or load history. Please try again."); + return response.status === 204 ? (undefined as T) : response.json(); +} + +export const historyService = { + list: ({ limit = 20, offset = 0, search = "" } = {}) => + request( + `/conversations?${new URLSearchParams({ limit: String(limit), offset: String(offset), search })}`, + ), + pin: (id: string, pinned: boolean) => + request(`/conversations/${encodeURIComponent(id)}/pin`, { + method: "PATCH", + body: JSON.stringify({ pinned }), + }), + async export(id: string, format: "json" | "markdown") { + const response = await userService.fetchWithAuth( + `/api/history/conversations/${encodeURIComponent(id)}/export?format=${format}`, + ); + if (!response.ok) + throw new Error("Unable to export this conversation. Please try again."); + const url = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + link.href = url; + link.download = `conversation-${id}.${format === "json" ? "json" : "md"}`; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, + memories: (offset = 0) => + request(`/memories?limit=100&offset=${offset}`), + correctMemory: (id: string, content: string) => + request(`/memories/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify({ content }), + }), + forgetMemory: (id: string) => + request(`/memories/${encodeURIComponent(id)}`, { method: "DELETE" }), +}; diff --git a/raggr-frontend/src/api/scheduledMessageService.ts b/raggr-frontend/src/api/scheduledMessageService.ts index 5881c08..815079a 100644 --- a/raggr-frontend/src/api/scheduledMessageService.ts +++ b/raggr-frontend/src/api/scheduledMessageService.ts @@ -1,13 +1,18 @@ import { userService } from "./userService"; +export type ScheduledMessageStatus = "pending" | "processing" | "sent" | "failed" | "uncertain" | "cancelled"; +export type DeliveryStatus = ScheduledMessageStatus | "sending"; + export interface ScheduledMessage { id: string; + series_id: string; + retryable: boolean; recipient: string; channel: "imessage" | "email"; content: string; subject: string | null; scheduled_at: string; - status: "pending" | "sent" | "failed" | "cancelled"; + status: ScheduledMessageStatus; recurrence: "none" | "daily" | "weekly" | "monthly"; use_agent: boolean; error_message: string | null; @@ -15,6 +20,23 @@ export interface ScheduledMessage { updated_at: string; } +export interface DeliveryAttempt { + id: string; + status: DeliveryStatus; + error: string | null; + started_at: string; + finished_at: string | null; +} + +export interface ScheduledDelivery { + id: string; + run_id: string; + status: DeliveryStatus; + error: string | null; + retryable: boolean; + attempts: DeliveryAttempt[]; +} + export interface CreateScheduledMessage { recipient: string; channel: "imessage" | "email"; @@ -25,12 +47,27 @@ export interface CreateScheduledMessage { use_agent?: boolean; } +export function canRetryScheduledMessage(message: Pick): boolean { + return message.status === "failed" && message.retryable === true; +} + +async function checkResponse(response: Response, fallback: string): Promise { + if (response.ok) return; + const payload = await response.json().catch(() => null); + const detail = payload?.error || payload?.error_message; + throw new Error(typeof detail === "string" ? detail : `${fallback}${payload?.status ? ` (${payload.status})` : ""}`); +} + class ScheduledMessageService { private baseUrl = "/api/scheduled-messages"; + private messageUrl(id: string): string { + return `${this.baseUrl}/${encodeURIComponent(id)}`; + } + async list(): Promise { const response = await userService.fetchWithAuth(`${this.baseUrl}/`); - if (!response.ok) throw new Error("Failed to list scheduled messages"); + await checkResponse(response, "Failed to list scheduled messages"); return response.json(); } @@ -39,33 +76,42 @@ class ScheduledMessageService { method: "POST", body: JSON.stringify(data), }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error ?? "Failed to create scheduled message"); - } + await checkResponse(response, "Failed to create scheduled message"); return response.json(); } - async update(id: string, data: Partial & { status?: string }): Promise { - const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, { + async update(id: string, data: Partial & { status?: "cancelled" }): Promise { + // Runtime guard also protects callers using untyped data: retry has its own endpoint. + if (data.status !== undefined && data.status !== "cancelled") { + throw new Error("Status changes require the explicit retry endpoint; only cancellation is allowed here"); + } + const response = await userService.fetchWithAuth(this.messageUrl(id), { method: "PUT", body: JSON.stringify(data), }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error ?? "Failed to update scheduled message"); - } + await checkResponse(response, "Failed to update scheduled message"); return response.json(); } async remove(id: string): Promise { - const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, { + const response = await userService.fetchWithAuth(this.messageUrl(id), { method: "DELETE", }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error ?? "Failed to delete scheduled message"); - } + await checkResponse(response, "Failed to delete scheduled message"); + } + + async deliveries(id: string): Promise { + const response = await userService.fetchWithAuth(`${this.messageUrl(id)}/deliveries`); + await checkResponse(response, "Failed to load delivery history"); + return response.json(); + } + + async retry(id: string): Promise { + const response = await userService.fetchWithAuth(`${this.messageUrl(id)}/retry`, { + method: "POST", + }); + await checkResponse(response, "Failed to retry scheduled message"); + return response.json(); } } diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index 05add45..956afd0 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -11,7 +11,10 @@ import { SidebarContent } from "./SidebarContent"; import { AdminPanel } from "./AdminPanel"; import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel"; import { ArtifactPanel } from "./artifacts/ArtifactPanel"; -import { AUTO_OPEN_MIN_WIDTH, useArtifactPanel } from "./artifacts/useArtifactPanel"; +import { + AUTO_OPEN_MIN_WIDTH, + useArtifactPanel, +} from "./artifacts/useArtifactPanel"; import { cn } from "../lib/utils"; import { useConversations } from "../hooks/useConversations"; import { useChat, type ToolMessage } from "../hooks/useChat"; @@ -19,435 +22,465 @@ import type { ArtifactRef } from "../api/artifactTypes"; import catIcon from "../assets/cat.png"; type ChatScreenProps = { - setAuthenticated: (isAuth: boolean) => void; - isAdmin: boolean; + setAuthenticated: (isAuth: boolean) => void; + isAdmin: boolean; }; export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { - const navigate = useNavigate(); - const [query, setQuery] = useState(""); - const [simbaMode, setSimbaMode] = useState(false); - const [showConversations, setShowConversations] = useState(false); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [showAdminPanel, setShowAdminPanel] = useState(false); - const [showScheduler, setShowScheduler] = useState(false); + const navigate = useNavigate(); + const [query, setQuery] = useState(""); + const [simbaMode, setSimbaMode] = useState(false); + const [showConversations, setShowConversations] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [showAdminPanel, setShowAdminPanel] = useState(false); + const [showScheduler, setShowScheduler] = useState(false); - const messagesEndRef = useRef(null); - const isLoadingRef = useRef(false); + const messagesEndRef = useRef(null); + const isLoadingRef = useRef(false); - const scrollToBottom = useCallback(() => { - requestAnimationFrame(() => { - messagesEndRef.current?.scrollIntoView({ - behavior: isLoadingRef.current ? "instant" : "smooth", - }); - }); - }, []); + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + messagesEndRef.current?.scrollIntoView({ + behavior: isLoadingRef.current ? "instant" : "smooth", + }); + }); + }, []); - const { - conversations, - selectedConversation, - createConversation, - renameConversation, - deleteConversation, - refreshConversations, - } = useConversations(); + const { + conversations, + selectedConversation, + createConversation, + renameConversation, + deleteConversation, + refreshConversations, + pinConversation, + exportConversation, + } = useConversations(); - const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]); + const onSessionExpired = useCallback( + () => setAuthenticated(false), + [setAuthenticated], + ); - const conversationId = useMatch("/c/:conversationId")?.params.conversationId; - const conversationIdRef = useRef(conversationId); - conversationIdRef.current = conversationId; + const conversationId = useMatch("/c/:conversationId")?.params.conversationId; + const conversationIdRef = useRef(conversationId); + conversationIdRef.current = conversationId; - const artifactPanel = useArtifactPanel(conversationId); - // Opens an artifact in the panel; the timeline's "Open" chip calls this. - const openArtifact = artifactPanel.open; + const artifactPanel = useArtifactPanel(conversationId); + // Opens an artifact in the panel; the timeline's "Open" chip calls this. + const openArtifact = artifactPanel.open; - // Auto-open on wide windows; newest wins. Narrower windows rely on the - // timeline chip or the header's Artifacts button. - const handleArtifact = useCallback( - (ref: ArtifactRef, turnConversationId: string) => { - if (turnConversationId !== conversationIdRef.current) return; - if (window.innerWidth < AUTO_OPEN_MIN_WIDTH) return; - openArtifact(ref, { auto: true }); - }, - [openArtifact], - ); + // Auto-open on wide windows; newest wins. Narrower windows rely on the + // timeline chip or the header's Artifacts button. + const handleArtifact = useCallback( + (ref: ArtifactRef, turnConversationId: string) => { + if (turnConversationId !== conversationIdRef.current) return; + if (window.innerWidth < AUTO_OPEN_MIN_WIDTH) return; + openArtifact(ref, { auto: true }); + }, + [openArtifact], + ); - const toggleArtifacts = useCallback(() => { - const p = artifactPanel; - if (p.isOpen && (p.view === "library" || !p.active)) p.collapse(); - else p.showLibrary(); - }, [artifactPanel]); + const toggleArtifacts = useCallback(() => { + const p = artifactPanel; + if (p.isOpen && (p.view === "library" || !p.active)) p.collapse(); + else p.showLibrary(); + }, [artifactPanel]); - const { - messages, - isLoading, - streaming, - thinking, - messagesLoading, - pendingAttachment, - attachFile, - clearAttachment, - attachmentError, - clearAttachmentError, - sendMessage, - stopGeneration, - error, - clearError, - stalled, - retryStalled, - thinkingText, - thinkingDurationMs, - } = useChat({ - createConversation, - refreshConversations, - onSessionExpired, - scrollToBottom, - onArtifact: handleArtifact, - }); + const { + messages, + isLoading, + streaming, + reconnecting, + thinking, + messagesLoading, + pendingAttachment, + attachFile, + clearAttachment, + attachmentError, + clearAttachmentError, + sendMessage, + stopGeneration, + error, + clearError, + stalled, + retryStalled, + thinkingText, + thinkingDurationMs, + } = useChat({ + createConversation, + refreshConversations, + onSessionExpired, + scrollToBottom, + onArtifact: handleArtifact, + }); - // Keep ref in sync for scrollToBottom behavior - isLoadingRef.current = isLoading; + // Keep ref in sync for scrollToBottom behavior + isLoadingRef.current = isLoading; - const handleSelectConversation = useCallback( - (conversation: { title: string; id: string }) => { - setShowConversations(false); - navigate(`/c/${conversation.id}`); - }, - [navigate], - ); + const handleSelectConversation = useCallback( + (conversation: { title: string; id: string }) => { + setShowConversations(false); + navigate(`/c/${conversation.id}`); + }, + [navigate], + ); - const handleCreateNewConversation = useCallback(() => { - setShowConversations(false); - navigate("/"); - }, [navigate]); + const handleCreateNewConversation = useCallback(() => { + setShowConversations(false); + navigate("/"); + }, [navigate]); - const handleQuestionSubmit = useCallback(() => { - sendMessage(query, simbaMode); - setQuery(""); - }, [query, simbaMode, sendMessage]); + const handleQuestionSubmit = useCallback(() => { + sendMessage(query, simbaMode); + setQuery(""); + }, [query, simbaMode, sendMessage]); - const handleQueryChange = useCallback((event: React.ChangeEvent) => { - setQuery(event.target.value); - }, []); + const handleQueryChange = useCallback( + (event: React.ChangeEvent) => { + setQuery(event.target.value); + }, + [], + ); - const handleKeyDown = useCallback((event: React.KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleQuestionSubmit(); - } - }, [handleQuestionSubmit]); + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleQuestionSubmit(); + } + }, + [handleQuestionSubmit], + ); - const handleToggleSimbaMode = useCallback(() => setSimbaMode((v) => !v), []); + const handleToggleSimbaMode = useCallback(() => setSimbaMode((v) => !v), []); - const handleDeleteConversation = useCallback( - (id: string) => deleteConversation(id), - [deleteConversation], - ); + const handleDeleteConversation = useCallback( + (id: string) => deleteConversation(id), + [deleteConversation], + ); - const handleLogout = () => { - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - setAuthenticated(false); - }; + const handleLogout = () => { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + setAuthenticated(false); + }; - const sidebarProps = { - conversations, - onCreateNewConversation: handleCreateNewConversation, - onSelectConversation: handleSelectConversation, - onRenameConversation: renameConversation, - onDeleteConversation: handleDeleteConversation, - selectedId: selectedConversation?.id, - isAdmin, - }; + const sidebarProps = { + conversations, + onCreateNewConversation: handleCreateNewConversation, + onSelectConversation: handleSelectConversation, + onRenameConversation: renameConversation, + onDeleteConversation: handleDeleteConversation, + onPinConversation: pinConversation, + onExportConversation: exportConversation, + selectedId: selectedConversation?.id, + isAdmin, + }; - return ( -
- {/* Desktop Sidebar */} - + return ( +
+ {/* Desktop Sidebar */} + - {/* Mobile drawer */} - {showConversations && ( -
- -
setShowConversations(false)} - /> -
- )} + {/* Mobile drawer */} + {showConversations && ( +
+ +
setShowConversations(false)} + /> +
+ )} - {showAdminPanel && setShowAdminPanel(false)} />} - {showScheduler && setShowScheduler(false)} />} + {showAdminPanel && ( + setShowAdminPanel(false)} /> + )} + {showScheduler && ( + setShowScheduler(false)} /> + )} -
- -
-
- Simba -

- asksimba -

-
-
- {conversationId && ( - - )} - -
-
+
+ +
+
+ Simba +

+ asksimba +

+
+
+ {conversationId && ( + + )} + +
+
- {conversationId && ( -
-

- {selectedConversation?.title} -

- -
- )} + {conversationId && ( +
+

+ {selectedConversation?.title} +

+ +
+ )} - {messages.length === 0 && !messagesLoading ? ( -
-
-
- Simba -
-

- Ask me anything -

-
- -
-
- ) : ( - <> -
-
- {messages.map((msg, index) => { - if (msg.speaker === "tool") { - // Consecutive tool calls render as one timeline block, - // emitted at the first row of the run. - if (index > 0 && messages[index - 1].speaker === "tool") { - return null; - } - let end = index; - while (end < messages.length && messages[end].speaker === "tool") { - end++; - } - const rows = messages.slice(index, end) as ToolMessage[]; - return ( - - ); - } + {messages.length === 0 && !messagesLoading ? ( +
+
+
+ Simba +
+

+ Ask me anything +

+
+ +
+
+ ) : ( + <> +
+
+ {messages.map((msg, index) => { + if (msg.speaker === "tool") { + // Consecutive tool calls render as one timeline block, + // emitted at the first row of the run. + if (index > 0 && messages[index - 1].speaker === "tool") { + return null; + } + let end = index; + while ( + end < messages.length && + messages[end].speaker === "tool" + ) { + end++; + } + const rows = messages.slice(index, end) as ToolMessage[]; + return ( + + ); + } - // When the model has been thinking and content is now streaming, - // render ThinkingBubble just before the streaming simba message - // so it appears above the answer rather than below it. - const showThinkingHere = - streaming && - isLoading && - (thinking || thinkingText || thinkingDurationMs !== null) && - index === messages.length - 1 && - msg.speaker === "simba"; + // When the model has been thinking and content is now streaming, + // render ThinkingBubble just before the streaming simba message + // so it appears above the answer rather than below it. + const showThinkingHere = + streaming && + isLoading && + (thinking || thinkingText || thinkingDurationMs !== null) && + index === messages.length - 1 && + msg.speaker === "simba"; - const bubble = - msg.speaker === "simba" ? ( - - ) : ( - - ); + const bubble = + msg.speaker === "simba" ? ( + + ) : ( + + ); - if (showThinkingHere) { - return ( - - - {bubble} - - ); - } - return bubble; - })} + if (showThinkingHere) { + return ( + + + {bubble} + + ); + } + return bubble; + })} - {/* ThinkingBubble before content arrives (no streaming message yet) */} - {isLoading && !streaming && (thinking || thinkingText || thinkingDurationMs !== null) && ( - - )} + {/* ThinkingBubble before content arrives (no streaming message yet) */} + {isLoading && + !streaming && + (thinking || thinkingText || thinkingDurationMs !== null) && ( + + )} - {/* Placeholder dots, unless the activity timeline already shows progress */} - {((isLoading && - !streaming && - !thinking && - messages[messages.length - 1]?.speaker !== "tool") || - messagesLoading) && ( - - )} + {/* Placeholder dots, unless the activity timeline already shows progress */} + {((isLoading && + !streaming && + !thinking && + messages[messages.length - 1]?.speaker !== "tool") || + messagesLoading) && } - {error && ( -
-
- {error} - -
-
- )} + {reconnecting && ( +

+ Connection lost. Reconnecting to your running answer… +

+ )} + {error && ( +
+
+ {error} + +
+
+ )} - {stalled && ( -
-
- Simba didn't respond in time. - -
-
- )} + {stalled && ( +
+
+ Simba didn't respond in time. + +
+
+ )} -
-
-
+
+
+
-
-
- -
-
- - )} -
+
+
+ +
+
+ + )} +
- -
- ); + +
+ ); }; diff --git a/raggr-frontend/src/components/ConversationList.tsx b/raggr-frontend/src/components/ConversationList.tsx index 5d31c32..78da35b 100644 --- a/raggr-frontend/src/components/ConversationList.tsx +++ b/raggr-frontend/src/components/ConversationList.tsx @@ -1,231 +1,322 @@ import { useEffect, useRef, useState } from "react"; -import { Check, MoreHorizontal, Pencil, Plus, Trash2, X } from "lucide-react"; +import { + Download, + Pin, + PinOff, + Check, + MoreHorizontal, + Pencil, + Plus, + Trash2, + X, +} from "lucide-react"; import { cn } from "../lib/utils"; -type Conversation = { - title: string; - id: string; -}; +import type { Conversation } from "../hooks/useConversations"; type ConversationProps = { - conversations: Conversation[]; - onSelectConversation: (conversation: Conversation) => void; - onCreateNewConversation: () => void; - onRenameConversation?: (id: string, title: string) => void; - onDeleteConversation?: (id: string) => void; - selectedId?: string; - variant?: "dark" | "light"; + conversations: Conversation[]; + onSelectConversation: (conversation: Conversation) => void; + onCreateNewConversation: () => void; + onRenameConversation?: (id: string, title: string) => void; + onDeleteConversation?: (id: string) => void; + onPinConversation?: (id: string, pinned: boolean) => void | Promise; + onExportConversation?: ( + id: string, + format: "json" | "markdown", + ) => void | Promise; + selectedId?: string; + variant?: "dark" | "light"; }; export const ConversationList = ({ - conversations, - onSelectConversation, - onCreateNewConversation, - onRenameConversation, - onDeleteConversation, - selectedId, - variant = "dark", + conversations, + onSelectConversation, + onCreateNewConversation, + onRenameConversation, + onDeleteConversation, + onPinConversation, + onExportConversation, + selectedId, + variant = "dark", }: ConversationProps) => { - const [menuOpenId, setMenuOpenId] = useState(null); - const [editingId, setEditingId] = useState(null); - const [editValue, setEditValue] = useState(""); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const inputRef = useRef(null); + const [menuOpenId, setMenuOpenId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [actionError, setActionError] = useState(""); + const runAction = async (action: () => void | Promise) => { + setMenuOpenId(null); + setActionError(""); + try { + await action(); + } catch { + setActionError("Unable to complete action. Please try again."); + } + }; + const inputRef = useRef(null); - const isDark = variant === "dark"; + const isDark = variant === "dark"; - // Close any open row menu when clicking elsewhere. - useEffect(() => { - if (!menuOpenId && !confirmDeleteId) return; - const close = () => { - setMenuOpenId(null); - setConfirmDeleteId(null); - }; - window.addEventListener("click", close); - return () => window.removeEventListener("click", close); - }, [menuOpenId, confirmDeleteId]); + // Close any open row menu when clicking elsewhere. + useEffect(() => { + if (!menuOpenId && !confirmDeleteId) return; + const close = () => { + setMenuOpenId(null); + setConfirmDeleteId(null); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + }; + window.addEventListener("click", close); + window.addEventListener("keydown", escape); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("keydown", escape); + }; + }, [menuOpenId, confirmDeleteId]); - useEffect(() => { - if (editingId) inputRef.current?.focus(); - }, [editingId]); + useEffect(() => { + if (editingId) inputRef.current?.focus(); + }, [editingId]); - const startRename = (conv: Conversation) => { - setMenuOpenId(null); - setEditingId(conv.id); - setEditValue(conv.title); - }; + const startRename = (conv: Conversation) => { + setMenuOpenId(null); + setEditingId(conv.id); + setEditValue(conv.title); + }; - const commitRename = () => { - if (editingId && editValue.trim()) { - onRenameConversation?.(editingId, editValue.trim()); - } - setEditingId(null); - }; + const commitRename = () => { + if (editingId && editValue.trim()) { + onRenameConversation?.(editingId, editValue.trim()); + } + setEditingId(null); + }; - const hasActions = Boolean(onRenameConversation || onDeleteConversation); + const hasActions = Boolean( + onRenameConversation || + onDeleteConversation || + onPinConversation || + onExportConversation, + ); - return ( -
- + return ( +
+ {actionError && ( +

+ {actionError} +

+ )} + - {conversations.map((conv) => { - const isActive = conv.id === selectedId; - const isEditing = editingId === conv.id; + {conversations.map((conv) => { + const isActive = conv.id === selectedId; + const isEditing = editingId === conv.id; - if (isEditing) { - return ( -
- setEditValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") commitRename(); - if (e.key === "Escape") setEditingId(null); - }} - className={cn( - "flex-1 min-w-0 bg-transparent px-1.5 py-1 text-sm rounded-lg", - "focus:outline-none", - isDark - ? "text-cream placeholder:text-cream/40" - : "text-charcoal placeholder:text-warm-gray/50", - )} - /> - - -
- ); - } + if (isEditing) { + return ( +
+ setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(); + if (e.key === "Escape") setEditingId(null); + }} + className={cn( + "flex-1 min-w-0 bg-transparent px-1.5 py-1 text-sm rounded-lg", + "focus:outline-none", + isDark + ? "text-cream placeholder:text-cream/40" + : "text-charcoal placeholder:text-warm-gray/50", + )} + /> + + +
+ ); + } - return ( -
- + return ( +
+ - {hasActions && ( - - )} + {hasActions && ( + + )} - {menuOpenId === conv.id && ( -
e.stopPropagation()} - className={cn( - "absolute right-1.5 top-9 z-20 w-36 rounded-xl p-1 shadow-lg", - isDark - ? "bg-sidebar-bg border border-white/10" - : "bg-warm-white border border-sand-light", - )} - > - {onRenameConversation && ( - - )} - {onDeleteConversation && ( - - )} -
- )} -
- ); - })} -
- ); + {menuOpenId === conv.id && ( +
e.stopPropagation()} + className={cn( + "absolute right-1.5 top-9 z-20 w-36 rounded-xl p-1 shadow-lg", + isDark + ? "bg-sidebar-bg border border-white/10" + : "bg-warm-white border border-sand-light", + )} + > + {onPinConversation && ( + + )} + {onExportConversation && + (["json", "markdown"] as const).map((format) => ( + + ))} + {onRenameConversation && ( + + )} + {onDeleteConversation && ( + + )} +
+ )} +
+ ); + })} +
+ ); }; diff --git a/raggr-frontend/src/components/ConversationsPage.tsx b/raggr-frontend/src/components/ConversationsPage.tsx index ac200ba..27befeb 100644 --- a/raggr-frontend/src/components/ConversationsPage.tsx +++ b/raggr-frontend/src/components/ConversationsPage.tsx @@ -8,87 +8,109 @@ import { Input } from "./ui/input"; import catIcon from "../assets/cat.png"; export const ConversationsPage = () => { - const navigate = useNavigate(); - const { - items, - search, - setSearch, - hasMore, - loading, - loadMore, - renameConversation, - deleteConversation, - } = useConversationBrowser(); + const navigate = useNavigate(); + const { + items, + error, + pinConversation, + exportConversation, + search, + setSearch, + hasMore, + loading, + loadMore, + renameConversation, + deleteConversation, + } = useConversationBrowser(); - const handleSelect = useCallback( - (conversation: Conversation) => navigate(`/c/${conversation.id}`), - [navigate], - ); + const handleSelect = useCallback( + (conversation: Conversation) => + navigate( + `/c/${conversation.id}${conversation.matchMessageId ? `?message=${encodeURIComponent(conversation.matchMessageId)}` : ""}`, + ), + [navigate], + ); - const handleCreateNew = useCallback(() => navigate("/"), [navigate]); + const handleCreateNew = useCallback(() => navigate("/"), [navigate]); - return ( -
-
- - Simba -

- All conversations -

-
+ return ( +
+
+ + Simba +

+ All conversations +

+
-
-
-
- - setSearch(e.target.value)} - placeholder="Search conversations..." - className="h-10 pl-9" - /> -
+
+
+
+ + setSearch(e.target.value)} + placeholder="Search titles and messages..." + aria-label="Search conversation titles and messages" + maxLength={500} + className="h-10 pl-9" + /> +
-
- + {error && ( +

+ {error} +

+ )} + {loading && ( +

+ Loading conversations... +

+ )} +
+ - {items.length === 0 && !loading && ( -

- {search ? "No conversations match your search." : "No conversations yet."} -

- )} -
+ {items.length === 0 && !loading && ( +

+ {search + ? "No conversations match your search." + : "No conversations yet."} +

+ )} +
- {hasMore && ( - - )} -
-
-
- ); + {hasMore && ( + + )} +
+
+
+ ); }; diff --git a/raggr-frontend/src/components/IntegrationStatusPanel.tsx b/raggr-frontend/src/components/IntegrationStatusPanel.tsx new file mode 100644 index 0000000..7533f87 --- /dev/null +++ b/raggr-frontend/src/components/IntegrationStatusPanel.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { X, RefreshCw } from "lucide-react"; +import { userService } from "../api/userService"; + +type Status = { name: string; status: string; message: string; last_success_at: string | null; indexed_chunks?: number }; + +export const IntegrationStatusPanel = ({ onClose }: { onClose: () => void }) => { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [revision, setRevision] = useState(0); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + void (async () => { + try { + const response = await userService.fetchWithAuth("/api/integrations/", { signal: controller.signal }); + if (!response.ok) throw new Error("Unable to load integration status."); + const body = await response.json(); + if (!controller.signal.aborted) setItems(body.integrations); + } catch (err) { + if (!controller.signal.aborted) setError(err instanceof Error ? err.message : "Unable to load integration status."); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + })(); + return () => controller.abort(); + }, [revision]); + useEffect(() => { + const close = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; + window.addEventListener("keydown", close); + return () => window.removeEventListener("keydown", close); + }, [onClose]); + return ( +
+
e.stopPropagation()}> +
+

Integrations and data freshness

+
+ + +
+
+ {loading &&

Checking data freshness…

} + {error &&

{error}

} +
    + {items.map((item) =>
  • +
    {item.name}{item.status.replace(/_/g, " ")}
    +

    {item.message}

    + {item.last_success_at &&

    Last synchronized: {new Date(item.last_success_at).toLocaleString()}

    } +
  • )} +
+
+
+ ); +}; diff --git a/raggr-frontend/src/components/MemoryPanel.tsx b/raggr-frontend/src/components/MemoryPanel.tsx new file mode 100644 index 0000000..cf6f28f --- /dev/null +++ b/raggr-frontend/src/components/MemoryPanel.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { historyService, type SavedMemory } from "../api/historyService"; + +type MemoryPanelProps = { onClose?: () => void }; + +/** Mount inside a page or dialog. Only the authenticated user's saved memories are loaded. */ +export function MemoryPanel({ onClose }: MemoryPanelProps) { + const [memories, setMemories] = useState([]); + const [loading, setLoading] = useState(true); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(null); + const [editing, setEditing] = useState(null); + const [content, setContent] = useState(""); + const [forgetting, setForgetting] = useState(null); + const [notice, setNotice] = useState(""); + const offset = useRef(0); + const generation = useRef(0); + + const load = useCallback(async (append = false) => { + const requestId = ++generation.current; + setLoading(true); + setError(""); + try { + const page = await historyService.memories(append ? offset.current : 0); + if (requestId !== generation.current) return; + offset.current = (append ? offset.current : 0) + page.length; + setMemories((previous) => + append + ? [ + ...previous, + ...page.filter((m) => !previous.some((old) => old.id === m.id)), + ] + : page, + ); + setHasMore(page.length === 100); + } catch { + if (requestId === generation.current) + setError("Unable to load memories. Please try again."); + } finally { + if (requestId === generation.current) setLoading(false); + } + }, []); + useEffect(() => { + void load(); + return () => { + ++generation.current; + }; + }, [load]); + + const save = async (memory: SavedMemory) => { + setBusy(memory.id); + setError(""); + try { + await historyService.correctMemory(memory.id, content.trim()); + setEditing(null); + setNotice( + "Memory corrected. Future responses will use the saved correction.", + ); + await load(); + } catch { + setError( + "Unable to correct memory. Your edit is still here; please try again.", + ); + } finally { + setBusy(null); + } + }; + const forget = async (memory: SavedMemory) => { + setBusy(memory.id); + setError(""); + try { + await historyService.forgetMemory(memory.id); + setForgetting(null); + if (editing === memory.id) setEditing(null); + setNotice( + "Memory forgotten. Existing conversation messages are retained.", + ); + await load(); + } catch { + setError("Unable to forget memory. Please try again."); + } finally { + setBusy(null); + } + }; + + return ( +
+
+

Saved memories

+ {onClose && ( + + )} +
+

+ Correct facts Simba remembers about you, or forget a saved memory. +

+ {error && ( +
+

{error}

+ +
+ )} + {notice && ( +

+ {notice} +

+ )} + {loading &&

Loading memories...

} + {!loading && !error && memories.length === 0 && ( +

No saved memories yet.

+ )} + {memories.map((memory) => ( +
+ {editing === memory.id ? ( + <> + +