Merge main into spending report totals and preserve artifact output

This commit is contained in:
2026-10-12 19:47:52 -04:00
55 changed files with 6641 additions and 208 deletions
+11
View File
@@ -24,7 +24,14 @@ BASE_URL=192.168.1.5:8000
# If set, uses llama-server as the primary LLM backend with OpenAI as fallback
LLAMA_SERVER_URL=http://192.168.1.213:8080/v1
LLAMA_MODEL_NAME=llama-3.1-8b-instruct
# Abandon a turn when an in-flight model call produces no output (prefill included)
# for this long. Thinking tokens count as output, so long reasoning is fine.
TTFT_TIMEOUT_SECONDS=30
# Context estimates include system instructions and tool definitions.
# Set the window to the smallest backend context; leave room for generation.
CONTEXT_WINDOW_TOKENS=32768
PROMPT_BUDGET_TOKENS=16000
RESPONSE_RESERVE_TOKENS=4096
# Embedding Server Configuration
# If set, uses a custom OpenAI-compatible embedding server (e.g. llama-server)
@@ -70,6 +77,8 @@ YNAB_SYNC_INTERVAL=900
MEALIE_BASE_URL=http://192.168.1.5:9000
# Get your API token from Mealie's user settings page
MEALIE_API_TOKEN=your-mealie-api-token
# Group slug used in recipe page links (/g/<slug>/r/<recipe>); defaults to "home"
MEALIE_GROUP_SLUG=home
# Email Integration
# Email Encryption Key (32-byte URL-safe base64)
@@ -167,6 +176,8 @@ OBSIDIAN_DEVICE_NAME=simbarag
OBSIDIAN_CONTINUOUS_SYNC=false
# Local path to Obsidian vault (where files are synced)
OBSIDIAN_VAULT_PATH=/app/data/obsidian
# Vault name for obsidian://open links in the artifact panel (optional; no link if unset)
OBSIDIAN_VAULT_NAME=
# LangGraph checkpoint store (SQLite). Must be on a persistent volume.
# Both blue and green containers share this volume via docker-compose.
+180 -14
View File
@@ -5,6 +5,7 @@ import os
import re
import time
import uuid
from urllib.parse import quote, unquote
from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import get_jwt_identity
@@ -16,9 +17,19 @@ from blueprints.users.decorators import jwt_access_required
from metrics import count_result, observe_response, time_response
from utils.image_process import image_content_block
from utils.strip_markdown import extract_agent_text
from utils.file_extract import (
FILE_TYPES,
FileExtractionError,
attachment_block,
extract_text,
file_extension,
)
from utils.image_upload import ALLOWED_TYPES as IMAGE_TYPES
from utils.image_upload import ImageValidationError, process_image
from utils.s3_client import generate_presigned_url as s3_presigned_url
from utils.s3_client import get_file as s3_get_file
from utils.s3_client import get_image as s3_get_image
from utils.s3_client import upload_file as s3_upload_file
from utils.s3_client import upload_image as s3_upload_image
from .logic import (
@@ -37,7 +48,35 @@ from .models import (
from .tool_events import tool_end_payload, tool_name_from_message, tool_start_payload
from .tool_labels import tool_label
TTFT_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
# Longest an in-flight model call may go without producing any output (prompt
# prefill included) before the turn is abandoned as stalled. Thinking tokens and
# empty chunks count as output; time spent running tools does not count at all.
MODEL_STALL_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30"))
def stall_timeout(last_model_activity: float | None, now: float) -> float | None:
"""Seconds left before the in-flight model call counts as stalled.
None when no model call is in flight (e.g. while tools run): the httpx read
timeout and the tools' own timeouts cover those phases.
"""
if last_model_activity is None:
return None
return max(MODEL_STALL_TIMEOUT - (now - last_model_activity), 1.0)
def _model_call_summary(call: dict) -> dict:
"""Per-LLM-call timing and token counts for the stream_request log line."""
start = call["start"]
first_chunk = call.get("first_chunk")
end = call.get("end")
return {
"prefill_ms": round((first_chunk - start) * 1000) if first_chunk else None,
"ms": round((end - start) * 1000) if end else None,
"prompt_tokens": call.get("prompt_tokens"),
"output_tokens": call.get("output_tokens"),
}
_THINK_OPEN = "<think>"
_THINK_CLOSE = "</think>"
@@ -203,6 +242,87 @@ async def upload_image():
return jsonify({"image_key": key})
def _file_key_belongs_to(file_key: str, conversation_id) -> bool:
"""Only allow attachments stored under this conversation's prefix."""
return (
isinstance(file_key, str)
and ".." not in file_key
and file_key.startswith(f"conversations/{conversation_id}/files/")
)
async def _load_attached_file(file_key: str) -> tuple[str, str]:
"""Fetch an attached file from S3 and return (original name, extracted text)."""
data, metadata = await s3_get_file(file_key)
name = unquote(metadata.get("filename", "")) or file_key.rsplit("/", 1)[-1]
ext = file_extension(file_key, None) or ""
# PyMuPDF is CPU-bound; keep it off the event loop so other streams don't stall.
return name, await asyncio.to_thread(extract_text, data, ext)
@conversation_blueprint.post("/upload")
@jwt_access_required
async def upload_attachment():
current_user_uuid = get_jwt_identity()
files = await request.files
form = await request.form
file = files.get("file")
conversation_id = form.get("conversation_id")
if not file or not conversation_id:
return jsonify({"error": "file and conversation_id are required"}), 400
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
if conversation is None:
return jsonify({"error": "conversation not found"}), 404
file_bytes = file.read()
content_type = (file.content_type or "").split(";")[0].strip().lower()
name = os.path.basename(file.filename or "") or "attachment"
if content_type in IMAGE_TYPES:
try:
processed_bytes, output_content_type = process_image(
file_bytes, content_type
)
except Exception as e:
return jsonify({"error": f"Could not process image: {e}"}), 400
ext = output_content_type.split("/")[-1]
if ext == "jpeg":
ext = "jpg"
key = f"conversations/{conversation.id}/{uuid.uuid4()}.{ext}"
await s3_upload_image(processed_bytes, key, output_content_type)
return jsonify({"kind": "image", "image_key": key})
ext = file_extension(name, content_type)
if ext is None:
return (
jsonify(
{
"error": f"Unsupported file type: {content_type or name}. "
"Allowed: images, PDF, TXT, MD, CSV"
}
),
415,
)
try:
text = await asyncio.to_thread(extract_text, file_bytes, ext)
except FileExtractionError as e:
return jsonify({"error": str(e)}), 400
if not text.strip():
message = (
"This PDF has no text layer (scanned?), so Simba can't read it yet."
if ext == "pdf"
else "This file is empty, so there's nothing for Simba to read."
)
return jsonify({"error": message}), 422
key = f"conversations/{conversation.id}/files/{uuid.uuid4()}.{ext}"
await s3_upload_file(file_bytes, key, FILE_TYPES[ext], {"filename": quote(name)})
return jsonify({"kind": "file", "file_key": key, "name": name, "chars": len(text)})
@conversation_blueprint.get("/image/<path:image_key>")
@jwt_access_required
async def serve_image(image_key: str):
@@ -219,12 +339,30 @@ async def stream_query():
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
try:
file_name, file_text = await _load_attached_file(file_key)
block = attachment_block(file_name, file_text)
except Exception as e:
logging.error(f"Failed to load file {file_key}: {e}")
file_name = file_key.rsplit("/", 1)[-1]
block = "[A file was attached but could not be loaded.]"
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=query_text or "",
message=saved_text,
speaker="user",
user=user,
image_key=image_key,
@@ -271,6 +409,9 @@ async def stream_query():
first_content_at: float | None = None
tool_timers: dict[str, float] = {} # run_id -> start time
tool_spans: list[dict] = []
# Last output from the in-flight model call; None between calls.
last_model_activity: float | None = None
model_calls: dict[str, dict] = {} # run_id -> timing / token usage
n_tokens = 0
model_name: str | None = None
cancelled = False
@@ -293,6 +434,8 @@ async def stream_query():
ttft_ms = (
round((first_content_at - t0) * 1000) if first_content_at else None
)
calls = [_model_call_summary(c) for c in model_calls.values()]
prompt_tokens = [c["prompt_tokens"] for c in calls if c["prompt_tokens"]]
logging.info(
json.dumps(
{
@@ -304,6 +447,8 @@ async def stream_query():
"ttft_ms": ttft_ms,
"tokens": n_tokens,
"tools": tool_spans,
"model_calls": calls,
"max_prompt_tokens": max(prompt_tokens, default=None),
"model": model_name,
"cancelled": cancelled,
**({"error": error} if error else {}),
@@ -316,15 +461,10 @@ async def stream_query():
payload, version="v2", config=agent_config
).__aiter__()
while True:
# Apply a short TTFT timeout only during the window between
# on_chat_model_start and the first content token. Before the
# LLM call starts (tool phase) and after the first token, let
# the httpx read timeout handle stalls instead.
if llm_start_at is not None and first_content_at is None:
ttft_wait = TTFT_TIMEOUT - (time.monotonic() - llm_start_at)
timeout = max(ttft_wait, 1.0)
else:
timeout = None
# Only a silent model call counts as a stall. A model that is
# thinking (or streaming empty chunks) keeps resetting the clock,
# and each new call starts a fresh one.
timeout = stall_timeout(last_model_activity, time.monotonic())
try:
if timeout is not None:
@@ -336,7 +476,9 @@ async def stream_query():
except StopAsyncIteration:
break
except asyncio.TimeoutError:
_log_summary(error=f"TTFT timeout after {TTFT_TIMEOUT:.0f}s")
_log_summary(
error=f"model stalled: no output for {MODEL_STALL_TIMEOUT:.0f}s"
)
yield f"data: {json.dumps({'type': 'error', 'message': 'Simba took too long to respond. Please try again.', 'stalled': True})}\n\n"
return
@@ -388,14 +530,34 @@ async def stream_query():
elif event_type == "on_chat_model_start":
turn_has_content = False
now = time.monotonic()
last_model_activity = now
model_calls[event["run_id"]] = {"start": now}
if llm_start_at is None:
llm_start_at = time.monotonic()
llm_start_at = now
if model_name is None:
model_name = event.get("metadata", {}).get(
"ls_model_name"
) or event.get("name")
elif event_type == "on_chat_model_end":
last_model_activity = None
call = model_calls.get(event["run_id"])
if call is not None:
call["end"] = time.monotonic()
usage = getattr(
event.get("data", {}).get("output"), "usage_metadata", None
)
if usage:
call["prompt_tokens"] = usage.get("input_tokens")
call["output_tokens"] = usage.get("output_tokens")
elif event_type == "on_chat_model_stream":
now = time.monotonic()
last_model_activity = now
call = model_calls.get(event.get("run_id"))
if call is not None and "first_chunk" not in call:
call["first_chunk"] = now
chunk = event.get("data", {}).get("chunk")
raw = getattr(chunk, "content", None)
if isinstance(raw, str) and raw:
@@ -457,7 +619,7 @@ async def stream_query():
_log_summary()
yield "data: [DONE]\n\n"
return await make_response(
response = await make_response(
event_generator(),
200,
{
@@ -466,6 +628,10 @@ async def stream_query():
"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
@conversation_blueprint.route("/<conversation_id>")
+229 -52
View File
@@ -1,4 +1,5 @@
import os
from functools import partial
from typing import cast
import httpx
@@ -8,9 +9,24 @@ from langchain.tools import tool
from langgraph.prebuilt import create_react_agent
from langchain_core.messages import SystemMessage
from langchain_core.runnables import RunnableConfig, RunnableLambda
from langchain_core.utils.function_calling import convert_to_openai_tool
from langchain_openai import ChatOpenAI
from tavily import AsyncTavilyClient
from blueprints.conversation.artifacts_util import (
NOTE_MAX_CHARS,
format_date_range,
format_day,
json_safe,
make_artifact,
mealie_source_ref,
note_title,
obsidian_source_ref,
search_documents_artifact,
truncate,
unique,
)
from blueprints.conversation.context import RESPONSE_RESERVE_TOKENS, build_context
from blueprints.conversation.memory import save_memory
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
from blueprints.rag.logic import query_vector_store
@@ -41,11 +57,14 @@ if llama_url:
timeout=httpx.Timeout(LLAMA_READ_TIMEOUT, connect=10.0),
max_retries=1,
model_kwargs={"reasoning_effort": "low"},
# Ask llama-server for token usage on the final chunk (prompt size logging).
stream_usage=True,
max_tokens=RESPONSE_RESERVE_TOKENS,
)
else:
llama_chat = None
openai_fallback = ChatOpenAI(model="gpt-5-mini")
openai_fallback = ChatOpenAI(model="gpt-5-mini", max_tokens=RESPONSE_RESERVE_TOKENS)
model_with_fallback = cast(
BaseChatModel,
llama_chat.with_fallbacks([openai_fallback]) if llama_chat else openai_fallback,
@@ -131,20 +150,36 @@ async def simba_search(query: str):
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]}")
return serialized, docs
return serialized, search_documents_artifact(query, "paperless", docs)
@tool
async def mealie_todays_meals() -> str:
MEALIE_NOT_CONFIGURED = "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
def _meal_entry(meal: dict) -> dict:
"""Meal plan entry in the `mealie.meal_plan` artifact shape."""
entry = {
"entry_type": meal["entry_type"],
"name": meal["recipe_name"] or meal["title"] or "Untitled",
}
if meal.get("recipe_slug"):
entry["recipe_slug"] = meal["recipe_slug"]
if meal.get("note"):
entry["note"] = meal["note"]
return entry
@tool(response_format="content_and_artifact")
async def mealie_todays_meals():
"""Get today's planned meals from Mealie."""
if not mealie_enabled:
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
return MEALIE_NOT_CONFIGURED, None
try:
result = await mealie_service.get_todays_meals()
if result["total_meals"] == 0:
return f"No meals planned for today ({result['today']})."
return f"No meals planned for today ({result['today']}).", None
lines = [f"Meals planned for today ({result['today']}):"]
for meal in result["meals"]:
@@ -152,13 +187,29 @@ async def mealie_todays_meals() -> str:
lines.append(f"- {meal['entry_type'].capitalize()}: {name}")
if meal["note"]:
lines.append(f" Note: {meal['note']}")
return "\n".join(lines)
today = result["today"]
artifact = make_artifact(
"mealie.meal_plan",
f"Today's meals · {format_day(today)}",
{
"start_date": today,
"end_date": today,
"days": [
{
"date": today,
"meals": [_meal_entry(m) for m in result["meals"]],
}
],
},
)
return "\n".join(lines), artifact
except Exception as e:
return f"Error fetching today's meals: {str(e)}"
return f"Error fetching today's meals: {str(e)}", None
@tool
async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
@tool(response_format="content_and_artifact")
async def mealie_meal_plans(start_date: str = "", end_date: str = ""):
"""Get Mealie meal plans for a date range.
Args:
@@ -166,7 +217,7 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
end_date: YYYY-MM-DD (default: 7 days out)
"""
if not mealie_enabled:
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
return MEALIE_NOT_CONFIGURED, None
try:
result = await mealie_service.get_meal_plans(
@@ -175,11 +226,15 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
)
if result["total_plans"] == 0:
return f"No meal plans found from {result['start_date']} to {result['end_date']}."
return (
f"No meal plans found from {result['start_date']} to {result['end_date']}.",
None,
)
lines = [
f"Meal plans from {result['start_date']} to {result['end_date']} ({result['total_plans']} meals):"
]
days = []
for date, plans in sorted(result["plans_by_date"].items()):
lines.append(f"\n{date}:")
for plan in plans:
@@ -187,20 +242,31 @@ async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str:
lines.append(f" - {plan['entry_type'].capitalize()}: {name}")
if plan["note"]:
lines.append(f" Note: {plan['note']}")
return "\n".join(lines)
days.append({"date": date, "meals": [_meal_entry(p) for p in plans]})
artifact = make_artifact(
"mealie.meal_plan",
f"Meal plan · {format_date_range(result['start_date'], result['end_date'])}",
{
"start_date": result["start_date"],
"end_date": result["end_date"],
"days": days,
},
)
return "\n".join(lines), artifact
except Exception as e:
return f"Error fetching meal plans: {str(e)}"
return f"Error fetching meal plans: {str(e)}", None
@tool
async def mealie_get_recipe(recipe_slug: str) -> str:
@tool(response_format="content_and_artifact")
async def mealie_get_recipe(recipe_slug: str):
"""Get full recipe details (ingredients, instructions, times) from Mealie.
Args:
recipe_slug: Recipe slug from meal plan data
"""
if not mealie_enabled:
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
return MEALIE_NOT_CONFIGURED, None
try:
recipe = await mealie_service.get_recipe(recipe_slug)
@@ -236,22 +302,49 @@ async def mealie_get_recipe(recipe_slug: str) -> str:
if recipe["tags"]:
lines.append(f"\nTags: {', '.join(recipe['tags'])}")
return "\n".join(lines)
instructions = []
for step in recipe["instructions"]:
instruction = {"text": step.get("text", "")}
if step.get("title"):
instruction["title"] = step["title"]
instructions.append(instruction)
slug = recipe.get("slug") or recipe_slug
artifact = make_artifact(
"mealie.recipe",
recipe["name"],
json_safe(
{
"slug": slug,
"name": recipe["name"],
"description": recipe["description"],
"servings": recipe["servings"],
"prep_time": recipe["prep_time"],
"cook_time": recipe["cook_time"],
"total_time": recipe["total_time"],
"ingredients": [ing["display"] for ing in recipe["ingredients"]],
"instructions": instructions,
"tags": recipe["tags"],
}
),
mealie_source_ref(slug),
)
return "\n".join(lines), artifact
except Exception as e:
return f"Error fetching recipe: {str(e)}"
return f"Error fetching recipe: {str(e)}", None
@tool
async def mealie_shopping_lists() -> str:
@tool(response_format="content_and_artifact")
async def mealie_shopping_lists():
"""Get Mealie shopping lists with needed and checked-off items."""
if not mealie_enabled:
return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables."
return MEALIE_NOT_CONFIGURED, None
try:
result = await mealie_service.get_shopping_lists()
if result["total_lists"] == 0:
return "No shopping lists found."
return "No shopping lists found.", None
lines = [f"Shopping lists ({result['total_unchecked_items']} items needed):"]
for sl in result["shopping_lists"]:
@@ -270,9 +363,37 @@ async def mealie_shopping_lists() -> str:
lines.append(
f" ({len(sl['checked_items'])} items already checked off)"
)
return "\n".join(lines)
lists = [
{
"name": sl["name"],
"items": [
{
"name": item["name"],
"quantity": item["quantity"],
"checked": False,
}
for item in sl["unchecked_items"]
]
+ [
{
"name": item["name"],
"quantity": item["quantity"],
"checked": True,
}
for item in sl["checked_items"]
],
}
for sl in result["shopping_lists"]
]
artifact = make_artifact(
"mealie.shopping_lists",
"Shopping lists",
json_safe({"lists": lists}),
)
return "\n".join(lines), artifact
except Exception as e:
return f"Error fetching shopping lists: {str(e)}"
return f"Error fetching shopping lists: {str(e)}", None
@tool
@@ -309,34 +430,63 @@ async def mealie_create_meal_plan(
return f"Error creating meal plan: {str(e)}"
@tool
async def obsidian_search_notes(query: str) -> str:
OBSIDIAN_NOT_CONFIGURED = "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
def _note_artifact(path: str, parsed: dict, title: str | None = None) -> dict:
"""`obsidian.note` artifact from ObsidianService.parse_markdown output."""
frontmatter = json_safe(parsed["metadata"])
title = title or note_title(path, frontmatter)
return make_artifact(
"obsidian.note",
title,
{
"path": path,
"title": title,
"markdown": truncate(parsed["content"], NOTE_MAX_CHARS),
"tags": unique(parsed["tags"]),
"frontmatter": frontmatter,
},
obsidian_source_ref(path),
)
@tool(response_format="content_and_artifact")
async def obsidian_search_notes(query: str):
"""Search personal Obsidian vault notes.
Args:
query: Search query
"""
if not obsidian_enabled:
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
return OBSIDIAN_NOT_CONFIGURED, None
try:
# Query vector store filtered to obsidian source only
serialized, docs = await query_vector_store(query=query, source="obsidian")
return serialized
return serialized, search_documents_artifact(query, "obsidian", docs)
except Exception as e:
return f"Error searching Obsidian notes: {str(e)}"
return f"Error searching Obsidian notes: {str(e)}", None
@tool
async def obsidian_read_note(relative_path: str) -> str:
"""Read the full content of a specific Obsidian note by path.
@tool(response_format="content_and_artifact")
async def obsidian_read_note(
relative_path: str, start_line: int = 1, max_lines: int | None = None
):
"""Read an Obsidian note, optionally selecting a small range of content lines.
Use line ranges to retrieve omitted sections of a large note.
Args:
relative_path: Path relative to vault root (e.g., "notes/my-note.md")
start_line: First content line to read, starting at 1 (excludes frontmatter).
max_lines: Number of lines to read, or None for all remaining lines.
"""
if not obsidian_enabled:
return "Obsidian integration is not configured. Please set OBSIDIAN_VAULT_PATH environment variable."
return OBSIDIAN_NOT_CONFIGURED, None
if start_line < 1 or (max_lines is not None and max_lines < 1):
return "start_line and max_lines must be positive integers.", None
try:
note = obsidian_service.read_note(relative_path)
@@ -344,16 +494,37 @@ async def obsidian_read_note(relative_path: str) -> str:
result = f"File: {note['path']}\n\n"
result += f"Frontmatter:\n{content_data['metadata']}\n\n"
result += f"Content:\n{content_data['content']}\n\n"
content = content_data["content"]
if start_line != 1 or max_lines is not None:
lines = content.splitlines()
if start_line > len(lines):
return (
f"File: {note['path']} has {len(lines)} content lines; start_line is beyond the end.",
None,
)
end_line = (
min(len(lines), start_line - 1 + max_lines)
if max_lines is not None
else len(lines)
)
content = "\n".join(lines[start_line - 1 : end_line])
result += (
f"Content lines {start_line}-{end_line} of {len(lines)}:\n{content}\n\n"
)
else:
result += f"Content:\n{content}\n\n"
result += f"Tags: {', '.join(content_data['tags'])}\n"
result += f"Contains {len(content_data['wikilinks'])} wikilinks and {len(content_data['embeds'])} embeds"
return result
return result, _note_artifact(note["path"], content_data)
except FileNotFoundError:
return f"Note not found at '{relative_path}'. Please check the path is correct."
return (
f"Note not found at '{relative_path}'. Please check the path is correct.",
None,
)
except Exception as e:
return f"Error reading note: {str(e)}"
return f"Error reading note: {str(e)}", None
@tool
@@ -391,19 +562,27 @@ async def obsidian_create_note(
return f"Error creating note: {str(e)}"
@tool
def journal_get_today() -> str:
@tool(response_format="content_and_artifact")
def journal_get_today():
"""Get today's full daily journal note including tasks and log entries."""
if not obsidian_enabled:
return "Obsidian integration is not configured."
return "Obsidian integration is not configured.", None
try:
note = obsidian_service.get_daily_note()
if not note["found"]:
return f"No daily note found for {note['date']}. Use journal_add_task to create one."
return f"Daily note for {note['date']}:\n\n{note['content']}"
return (
f"No daily note found for {note['date']}. Use journal_add_task to create one.",
None,
)
artifact = _note_artifact(
note["path"],
obsidian_service.parse_markdown(note["content"]),
title=f"Daily note · {format_day(note['date'])}",
)
return f"Daily note for {note['date']}:\n\n{note['content']}", artifact
except Exception as e:
return f"Error reading daily note: {str(e)}"
return f"Error reading daily note: {str(e)}", None
@tool
@@ -950,8 +1129,7 @@ async def beli_search_restaurants(
tools.append(beli_search_restaurants)
@RunnableLambda
def _build_prompt(state, config: RunnableConfig = None) -> list:
def _build_prompt(state, config: RunnableConfig = None, *, tool_schemas=()) -> list:
"""Inject the per-request system prompt, replacing any prior system messages."""
system_prompt = (
(config or {}).get("configurable", {}).get("system_prompt", SIMBA_SYSTEM_PROMPT)
@@ -959,10 +1137,7 @@ def _build_prompt(state, config: RunnableConfig = None) -> list:
# LangGraph passes the full state dict {"messages": [...]}, not just the list
messages = state["messages"] if isinstance(state, dict) else state
non_system = [m for m in messages if not isinstance(m, SystemMessage)]
# Keep only the most recent messages to stay within the 32k context window.
# Tool call/result pairs each count as 2 messages, so 12 ≈ 4-6 conversational turns.
non_system = non_system[-12:]
return [SystemMessage(content=system_prompt)] + non_system
return build_context(non_system, system_prompt, list(tool_schemas))
def build_agent(checkpointer=None, extra_tools=()):
@@ -971,9 +1146,11 @@ def build_agent(checkpointer=None, extra_tools=()):
``extra_tools`` are appended to the shared tool list, e.g. admin-only
Homebox and BookStack tools for the admin agent.
"""
agent_tools = tools + list(extra_tools)
tool_schemas = [convert_to_openai_tool(t) for t in agent_tools]
return create_react_agent(
model=model_with_fallback,
tools=tools + list(extra_tools),
prompt=_build_prompt,
tools=agent_tools,
prompt=RunnableLambda(partial(_build_prompt, tool_schemas=tool_schemas)),
checkpointer=checkpointer,
)
+178
View File
@@ -0,0 +1,178 @@
"""Helpers for building ToolArtifact dicts returned by agent tools.
See docs/cowork-ui-plan.md ("Artifacts") for the contract. Artifacts must be
plain JSON-serialisable dicts: they ride along in ToolMessage.artifact and are
stored in the LangGraph checkpoint.
"""
import os
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from urllib.parse import quote, urlencode
SNIPPET_MAX_CHARS = 400
NOTE_MAX_CHARS = 50_000
ELLIPSIS = "…"
def make_artifact(
kind: str, title: str, data: dict, source_ref: dict | None = None
) -> dict:
artifact = {"kind": kind, "title": title, "data": data}
if source_ref:
artifact["source_ref"] = source_ref
return artifact
def truncate(text: str, limit: int) -> str:
"""Cap text at `limit` chars, ending in an ellipsis when cut."""
if len(text) <= limit:
return text
return text[: limit - len(ELLIPSIS)].rstrip() + ELLIPSIS
def snippet(text: str, limit: int = SNIPPET_MAX_CHARS) -> str:
"""Collapse whitespace and cap at the snippet limit."""
return truncate(" ".join((text or "").split()), limit)
def json_safe(value):
"""Convert a value (e.g. parsed YAML frontmatter) to JSON-safe types."""
if isinstance(value, (str, int, float, bool, type(None))):
return value
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, (list, tuple, set)):
return [json_safe(v) for v in value]
if isinstance(value, dict):
return {str(k): json_safe(v) for k, v in value.items()}
return str(value)
def unique(items) -> list:
"""Deduplicate while keeping first-seen order."""
return list(dict.fromkeys(items))
def _parse_date(value: str) -> date | None:
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except (TypeError, ValueError):
return None
def format_day(value: str) -> str:
"""'2026-10-12' -> 'Oct 12'. Unparseable input is returned unchanged."""
d = _parse_date(value)
return f"{d:%b} {d.day}" if d else str(value)
def format_month(value: str) -> str:
"""'2026-10' or '2026-10-01' -> 'October 2026'. Unparseable input is returned unchanged."""
d = _parse_date(str(value)[:7] + "-01")
return f"{d:%B %Y}" if d else str(value)
def format_date_range(start: str, end: str) -> str:
"""Human date range: 'Oct 12–19', 'Oct 28–Nov 3', 'Dec 28, 2026–Jan 3, 2027'."""
s, e = _parse_date(start), _parse_date(end)
if not s or not e:
return f"{start}–{end}"
if s == e:
return f"{s:%b} {s.day}"
if s.year != e.year:
return f"{s:%b} {s.day}, {s.year}–{e:%b} {e.day}, {e.year}"
if s.month == e.month:
return f"{s:%b} {s.day}–{e.day}"
return f"{s:%b} {s.day}–{e:%b} {e.day}"
def epoch_to_date(value) -> str | None:
"""Epoch seconds -> 'YYYY-MM-DD' (None if not a number)."""
try:
return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d")
except (TypeError, ValueError, OverflowError, OSError):
return None
def mealie_recipe_url(slug: str) -> str | None:
"""Recipe page URL for Mealie v2 (`/g/<group-slug>/r/<slug>`)."""
base_url = os.getenv("MEALIE_BASE_URL", "").rstrip("/")
if not base_url:
return None
group = os.getenv("MEALIE_GROUP_SLUG", "") or "home"
return f"{base_url}/g/{quote(group)}/r/{quote(slug)}"
def mealie_source_ref(slug: str) -> dict:
ref = {"system": "mealie", "id": slug}
url = mealie_recipe_url(slug)
if url:
ref["url"] = url
return ref
def obsidian_source_ref(path: str) -> dict:
"""Obsidian ref; the obsidian:// URL needs OBSIDIAN_VAULT_NAME."""
ref = {"system": "obsidian", "path": path}
vault = os.getenv("OBSIDIAN_VAULT_NAME", "")
if vault:
ref["url"] = "obsidian://open?" + urlencode(
{"vault": vault, "file": path}, quote_via=quote
)
return ref
def note_title(path: str, frontmatter: dict | None = None) -> str:
"""Frontmatter `title` if present, else the filename without extension."""
title = (frontmatter or {}).get("title")
if isinstance(title, str) and title.strip():
return title.strip()
return Path(path).stem or path
def vault_relative_path(filepath: str) -> str:
"""Strip OBSIDIAN_VAULT_PATH from an absolute indexed filepath."""
vault = os.getenv("OBSIDIAN_VAULT_PATH", "/app/data/obsidian")
try:
return str(Path(filepath).relative_to(vault))
except ValueError:
return filepath
def search_documents_artifact(query: str, source: str, docs) -> dict | None:
"""Build a `search.documents` artifact from vector-store Documents."""
if not docs:
return None
results = []
for doc in docs:
meta = doc.metadata or {}
result = {"snippet": snippet(doc.page_content)}
if source == "obsidian":
filepath = meta.get("filepath") or ""
path = vault_relative_path(filepath) if filepath else ""
result["title"] = note_title(path, meta) if path else "Untitled note"
if path:
result["path"] = path
created = meta.get("created_at")
if isinstance(created, str) and created:
result["date"] = created[:10]
else:
filename = meta.get("filename") or ""
result["title"] = Path(filename).stem or filename or "Document"
if filename:
result["filename"] = filename
if meta.get("document_type"):
result["document_type"] = str(meta["document_type"])
created = epoch_to_date(meta.get("created_date"))
if created:
result["date"] = created
results.append(result)
return make_artifact(
"search.documents",
f"Search: {query}",
{"query": query, "source": source, "results": results},
)
+286
View File
@@ -0,0 +1,286 @@
"""Budget the model's view of history without changing checkpointed messages.
Token estimates use UTF-8 bytes / 3 plus message overhead. They are deliberately
more conservative than the usual chars / 4 heuristic, but are not a model
specific tokenizer. A safety reserve covers estimation and chat-template costs.
"""
import json
import logging
import math
import os
from dotenv import load_dotenv
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
load_dotenv()
CONTEXT_WINDOW_TOKENS = int(os.getenv("CONTEXT_WINDOW_TOKENS", "32768"))
PROMPT_BUDGET_TOKENS = int(os.getenv("PROMPT_BUDGET_TOKENS", "16000"))
RESPONSE_RESERVE_TOKENS = int(os.getenv("RESPONSE_RESERVE_TOKENS", "4096"))
SAFETY_RESERVE_TOKENS = 2048
EARLIER_MESSAGE_MAX_CHARS = 4000
TOOL_RESULT_MAX_CHARS = 12000
TRUNCATION_NOTE = "\n[truncated: earlier context]"
IMAGE_PLACEHOLDER = "[image from an earlier message]"
def estimate_tokens(value) -> int:
text = (
value
if isinstance(value, str)
else json.dumps(value, ensure_ascii=False, default=str)
)
return math.ceil(len(text.encode("utf-8")) / 3)
def message_tokens(message: BaseMessage) -> int:
# Count tool arguments but never artifact payloads (not sent to the model).
content = message.content
images = 0
if isinstance(content, list):
images = sum(
isinstance(p, dict) and p.get("type") in ("image", "image_url")
for p in content
)
content = [
p
for p in content
if not (isinstance(p, dict) and p.get("type") in ("image", "image_url"))
]
return (
16
+ estimate_tokens(content)
+ images * 4096
+ estimate_tokens(getattr(message, "tool_calls", []))
)
def _cap(text: str, limit: int, note: str) -> str:
if len(text) <= limit:
return text
# Retain both ends: identifiers and totals often appear at the end of results.
head = limit * 3 // 4
return text[:head] + note + (text[-(limit - head) :] if limit > head else "")
def _shrink(message: BaseMessage, limit: int, *, earlier: bool) -> BaseMessage:
note = (
TRUNCATION_NOTE
if earlier
else "\n[tool result excerpt; omitted content can be fetched again with the source tool]\n"
)
content = message.content
if isinstance(content, str):
new_content = _cap(content, limit, note)
else:
new_content = []
remaining = limit
for part in content:
if (
isinstance(part, dict)
and part.get("type") in ("image_url", "image")
and earlier
):
new_content.append({"type": "text", "text": IMAGE_PLACEHOLDER})
elif isinstance(part, dict) and isinstance(part.get("text"), str):
text = _cap(part["text"], remaining, note)
new_content.append({**part, "text": text})
remaining = max(0, remaining - min(len(part["text"]), remaining))
elif isinstance(part, str):
new_content.append(_cap(part, remaining, note))
remaining = max(0, remaining - min(len(part), remaining))
else:
new_content.append(part)
return (
message
if new_content == content
else message.model_copy(update={"content": new_content})
)
def _turns(messages: list[BaseMessage]) -> list[list[BaseMessage]]:
turns = []
for message in messages:
if isinstance(message, HumanMessage):
turns.append([])
if turns:
turns[-1].append(message)
# Leading orphaned checkpoint fragments have no user question to anchor them.
return turns
def _complete_exchanges(turn: list[BaseMessage]) -> list[BaseMessage]:
"""Drop unfinished exchanges left by a cancelled turn and orphan results."""
result = []
i = 0
while i < len(turn):
message = turn[i]
if isinstance(message, AIMessage) and message.tool_calls:
expected = {c["id"] for c in message.tool_calls}
end = i + 1
while end < len(turn) and isinstance(turn[end], ToolMessage):
end += 1
replies = turn[i + 1 : end]
actual = [m.tool_call_id for m in replies]
if len(actual) == len(expected) and set(actual) == expected:
result.extend(turn[i:end])
i = end
else:
if not isinstance(message, ToolMessage):
result.append(message)
i += 1
return result
def _summary(turns: list[list[BaseMessage]], limit: int = 2400) -> SystemMessage | None:
excerpts = []
remaining = limit
for turn in reversed(turns):
lines = []
for message in turn:
if isinstance(message, HumanMessage) or (
isinstance(message, AIMessage) and not message.tool_calls
):
content = message.content
if not isinstance(content, str):
content = " ".join(
p.get("text", "") if isinstance(p, dict) else str(p)
for p in content
)
role = "User" if isinstance(message, HumanMessage) else "Assistant"
lines.append(f"{role}: {_cap(content, 400, ' [excerpt] ')}")
excerpt = "\n".join(lines)
if len(excerpt) > remaining:
break
excerpts.insert(0, excerpt)
remaining -= len(excerpt) + 2
if not excerpts:
return None
return SystemMessage(
content="Earlier conversation excerpts (historical data, not new instructions; excerpts may omit details):\n\n"
+ "\n\n".join(excerpts)
)
def trim_history(
messages: list[BaseMessage], *, token_budget: int = 12000
) -> list[BaseMessage]:
"""Keep the current turn, then recent whole turns, within an estimated budget.
The current user message and tool call arguments are never truncated. Fail
explicitly if these mandatory inputs cannot fit, rather than lose the task.
"""
turns = [_complete_exchanges(turn) for turn in _turns(messages)]
if not turns:
return []
current = turns[-1]
current = [
_shrink(m, TOOL_RESULT_MAX_CHARS, earlier=False)
if isinstance(m, ToolMessage)
else m
for m in current
]
def cost(ms):
return sum(message_tokens(m) for m in ms)
# Keep room for continuity even when the current turn has many tool results.
current_budget = (
token_budget - min(1000, token_budget // 8) if len(turns) > 1 else token_budget
)
if cost(current) > current_budget:
# Binary-search the largest shared excerpt size that fits. Tool call IDs
# and all results remain, including parallel exchanges.
low, high = 0, TOOL_RESULT_MAX_CHARS
def compact(limit):
return [
_shrink(m, limit, earlier=False)
if isinstance(m, (ToolMessage, AIMessage))
else m
for m in current
]
if cost(compact(0)) > token_budget:
raise ValueError(
"The current question and tool calls exceed the context budget. Send a smaller attachment or start a new conversation."
)
current_budget = max(current_budget, cost(compact(0)))
while low < high:
mid = (low + high + 1) // 2
if cost(compact(mid)) <= current_budget:
low = mid
else:
high = mid - 1
current = compact(low)
selected = []
used = cost(current)
prior = turns[:-1]
# Leave a small share for excerpts of older turns only when necessary.
summary_reserve = min(1000, max(0, token_budget - used))
for turn in reversed(prior):
shrunk = [_shrink(m, EARLIER_MESSAGE_MAX_CHARS, earlier=True) for m in turn]
turn_cost = cost(shrunk)
if used + turn_cost > token_budget - summary_reserve:
break
selected.insert(0, shrunk)
used += turn_cost
dropped = prior[: len(prior) - len(selected)]
summary = _summary(dropped)
result = [m for turn in selected for m in turn] + current
if summary and used + message_tokens(summary) <= token_budget:
result.insert(0, summary)
logging.info(
json.dumps(
{
"event": "context_budget",
"budget_tokens": token_budget,
"estimated_tokens": cost(result),
"input_messages": len(messages),
"output_messages": len(result),
"dropped_turns": len(dropped),
"summary_included": bool(
result and isinstance(result[0], SystemMessage)
),
}
)
)
return result
def build_context(
messages: list[BaseMessage], system_prompt: str, tool_schemas: list[dict]
) -> list[BaseMessage]:
limit = min(
PROMPT_BUDGET_TOKENS,
CONTEXT_WINDOW_TOKENS - RESPONSE_RESERVE_TOKENS - SAFETY_RESERVE_TOKENS,
)
system = SystemMessage(content=system_prompt)
budget = limit - message_tokens(system) - estimate_tokens(tool_schemas)
if budget <= 0:
raise ValueError(
"System instructions and tool definitions exceed the configured prompt budget."
)
history = trim_history(messages, token_budget=budget)
logging.info(
json.dumps(
{
"event": "prompt_budget",
"limit_tokens": limit,
"system_tokens": message_tokens(system),
"tool_schema_tokens": estimate_tokens(tool_schemas),
"estimated_prompt_tokens": message_tokens(system)
+ estimate_tokens(tool_schemas)
+ sum(message_tokens(m) for m in history),
"response_reserve_tokens": RESPONSE_RESERVE_TOKENS,
}
)
)
return [system] + history
+13 -1
View File
@@ -1,5 +1,8 @@
from .models import UserMemory
from .prompts import SIMBA_SYSTEM_PROMPT
from .context import estimate_tokens
MEMORY_BUDGET_TOKENS = 1000
async def get_memories_for_user(user_id: str) -> list[str]:
@@ -12,7 +15,16 @@ async def build_system_prompt_with_memories(user_id: str) -> str:
memories = await get_memories_for_user(user_id)
if not memories:
return SIMBA_SYSTEM_PROMPT
block = "\n".join(f"- {m}" for m in memories)
selected = []
used = 0
for memory in memories:
cost = estimate_tokens(f"- {memory}\n")
if used + cost <= MEMORY_BUDGET_TOKENS:
selected.append(memory)
used += cost
block = "\n".join(f"- {m}" for m in selected)
if len(selected) < len(memories):
block += "\n[Additional saved memories omitted from this prompt.]"
return f"{SIMBA_SYSTEM_PROMPT}\n\nUSER MEMORIES (facts the user has asked you to remember):\n{block}"
+131 -21
View File
@@ -14,6 +14,13 @@ from zoneinfo import ZoneInfo
from langchain.tools import tool
from tortoise import connections
from blueprints.conversation.artifacts_util import (
format_date_range,
format_month,
json_safe,
make_artifact,
)
from .sync import ensure_fresh
logger = logging.getLogger(__name__)
@@ -23,6 +30,8 @@ NOT_SYNCED = "YNAB data is unavailable: the budget has not been synced yet."
# Keep in step with TOOL_RESULT_MAX_CHARS in blueprints/conversation/logic.py;
# longer results get truncated there anyway and crowd the 32k context.
MAX_TRANSACTIONS = 40
# Rows in the ynab.transactions artifact (the side panel, not the LLM)
ARTIFACT_MAX_TRANSACTIONS = 500
# ynab_recent_changes accepts the names it prints as well as stored types
ENTITY_TYPE_ALIASES = {
@@ -64,6 +73,19 @@ def ynab_tool_errors(fn):
return wrapper
def ynab_artifact_tool(fn):
"""For content_and_artifact tools: errors become (text, None), and bare
strings (not-synced, bad input, empty results) get no artifact."""
wrapped = ynab_tool_errors(fn)
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
result = await wrapped(*args, **kwargs)
return (result, None) if isinstance(result, str) else result
return wrapper
# Every transaction line, with split transactions expanded into their
# subtransactions so category/payee filters see the real allocation.
LINES_CTE = """
@@ -114,6 +136,11 @@ def _usd(value: Decimal | float | None) -> str:
return f"{sign}${abs(value):,.2f}"
def _amount(value: Decimal | float | None) -> float:
"""Dollar amount for artifacts (JSON has no Decimal)."""
return float(value or 0)
def _parse_date(value: str, default: date | None) -> date | None:
return date.fromisoformat(value) if value else default
@@ -128,9 +155,9 @@ async def _query(sql: str, *params) -> list[dict]:
return await connections.get("default").execute_query_dict(sql, list(params))
@tool
@ynab_tool_errors
async def ynab_budget_summary(month: str = "") -> str:
@tool(response_format="content_and_artifact")
@ynab_artifact_tool
async def ynab_budget_summary(month: str = ""):
"""Get YNAB budget overview for a month: Ready to Assign, income, assigned, spent, age of money, overspent categories, and net worth.
Args:
@@ -191,12 +218,29 @@ async def ynab_budget_summary(month: str = "") -> str:
)
else:
lines.append("No overspent categories.")
net_worth = sum(by_budget.values(), Decimal(0))
lines.append(
f"Net worth: {_usd(sum(by_budget.values(), Decimal(0)))} "
f"Net worth: {_usd(net_worth)} "
f"(budget accounts {_usd(by_budget.get(True))}, "
f"tracking accounts {_usd(by_budget.get(False))})"
)
return "\n".join(lines)
data = {
"month": f"{start:%Y-%m}",
"ready_to_assign": _amount(m["to_be_budgeted"]),
"income": _amount(m["income"]),
"assigned": _amount(m["budgeted"]),
"spent": _amount(spent[0]["spent"]),
"net_worth": _amount(net_worth),
"overspent": [
{"name": r["name"], "available": _amount(r["balance"])} for r in overspent
],
}
if m["age_of_money"] is not None:
data["age_of_money"] = m["age_of_money"]
artifact = make_artifact(
"ynab.summary", f"Budget · {format_month(data['month'])}", json_safe(data)
)
return "\n".join(lines), artifact
@tool
@@ -230,8 +274,8 @@ async def ynab_account_balances(include_closed: bool = False) -> str:
return "\n".join(lines) + f"\n\nNet worth (open accounts): {_usd(total)}"
@tool
@ynab_tool_errors
@tool(response_format="content_and_artifact")
@ynab_artifact_tool
async def ynab_search_transactions(
start_date: str = "",
end_date: str = "",
@@ -242,7 +286,7 @@ async def ynab_search_transactions(
min_amount: float = 0,
max_amount: float = 0,
limit: int = 20,
) -> str:
):
"""Search YNAB transactions. Split transactions are expanded into their category lines.
Args:
@@ -305,12 +349,16 @@ async def ynab_search_transactions(
return f"No transactions found from {range_text}."
limit = min(max(1, limit), MAX_TRANSACTIONS)
rows = await _query(
# One query feeds both the text (first `limit` rows) and the artifact; the
# id tie-break keeps the order stable regardless of LIMIT.
all_rows = await _query(
f"""{LINES_CTE}
SELECT * FROM lines l WHERE {where}
ORDER BY l.date DESC, abs(l.amount) DESC LIMIT {limit}""",
ORDER BY l.date DESC, abs(l.amount) DESC, l.id
LIMIT {ARTIFACT_MAX_TRANSACTIONS}""",
*params,
)
rows = all_rows[:limit]
lines = [
f"Found {count} transactions from {range_text}. "
f"Outflows {_usd(totals[0]['outflow'])}, inflows {_usd(totals[0]['inflow'])}."
@@ -327,11 +375,38 @@ async def ynab_search_transactions(
lines.append(line)
if count > len(rows):
lines.append(f"(Showing {len(rows)} most recent of {count})")
return "\n".join(lines)
transactions = []
for r in all_rows:
txn = {
"date": r["date"],
"payee": r["payee_name"] or "Unknown payee",
"category": r["category_name"] or "Uncategorized",
"account": r["account_name"],
"amount": _amount(r["amount"]),
}
if r["memo"]:
txn["memo"] = r["memo"]
transactions.append(txn)
title_range = format_date_range(str(start), str(end)) if start else f"through {end}"
artifact = make_artifact(
"ynab.transactions",
f"Transactions · {title_range}",
json_safe(
{
"start_date": start,
"end_date": end,
"count": count,
"total": _amount(totals[0]["outflow"] + totals[0]["inflow"]),
"transactions": transactions,
}
),
)
return "\n".join(lines), artifact
@tool
@ynab_tool_errors
@tool(response_format="content_and_artifact")
@ynab_artifact_tool
async def ynab_spending_report(
group_by: str = "category",
start_date: str = "",
@@ -339,7 +414,7 @@ async def ynab_spending_report(
category: str = "",
payee: str = "",
top: int = 15,
) -> str:
):
"""Total YNAB spending grouped by category, category_group, payee, account, or month. Use group_by='month' for trends over time.
Args:
@@ -377,7 +452,7 @@ async def ynab_spending_report(
JOIN ynab_accounts a ON a.id = l.account_id
LEFT JOIN ynab_categories c ON c.id = l.category_id
LEFT JOIN ynab_category_groups g ON g.id = c.category_group_id
WHERE {' AND '.join(conditions)}
WHERE {" AND ".join(conditions)}
GROUP BY 1 ORDER BY {order}""",
*params,
)
@@ -392,7 +467,7 @@ async def ynab_spending_report(
FROM lines l
JOIN ynab_accounts a ON a.id = l.account_id
LEFT JOIN ynab_categories c ON c.id = l.category_id
WHERE {' AND '.join(conditions)}""",
WHERE {" AND ".join(conditions)}""",
*params,
)
outflows, inflows = totals[0]["outflows"], totals[0]["inflows"]
@@ -409,12 +484,29 @@ async def ynab_spending_report(
lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}")
if group_by == "month" and len(rows) > 1:
lines.append(f"Monthly average: {_usd(outflows / len(rows))}")
return "\n".join(lines)
artifact = make_artifact(
"ynab.spending_report",
f"Spending by {group_by.replace('_', ' ')} · "
f"{format_date_range(str(start), str(end))}",
json_safe(
{
"start_date": start,
"end_date": end,
"group_by": group_by,
"rows": [
{"key": r["key"], "amount": _amount(r["spent"]), "count": r["n"]}
for r in rows
],
"total": _amount(outflows),
}
),
)
return "\n".join(lines), artifact
@tool
@ynab_tool_errors
async def ynab_category_budget(month: str = "", category_group: str = "") -> str:
@tool(response_format="content_and_artifact")
@ynab_artifact_tool
async def ynab_category_budget(month: str = "", category_group: str = ""):
"""Get YNAB categories for a month grouped by category group: assigned, activity (negative = spent), available, and goal target. Flags overspending.
Args:
@@ -447,12 +539,23 @@ async def ynab_category_budget(month: str = "", category_group: str = "") -> str
lines = [f"Categories for {start:%Y-%m}:"]
current = None
groups = []
for r in rows:
if not (r["budgeted"] or r["activity"] or r["balance"]):
continue
if r["group_name"] != current:
current = r["group_name"]
lines.append(f"\n{current}")
groups.append({"name": current, "categories": []})
category = {
"name": r["name"],
"assigned": _amount(r["budgeted"]),
"activity": _amount(r["activity"]),
"available": _amount(r["balance"]),
}
if r["goal_target"]:
category["goal_target"] = _amount(r["goal_target"])
groups[-1]["categories"].append(category)
line = (
f" {r['name']}: assigned {_usd(r['budgeted'])}, "
f"activity {_usd(r['activity'])}, available {_usd(r['balance'])}"
@@ -462,7 +565,14 @@ async def ynab_category_budget(month: str = "", category_group: str = "") -> str
if r["balance"] < 0:
line += " [OVERSPENT]"
lines.append(line)
return "\n".join(lines)
artifact = None
if groups:
artifact = make_artifact(
"ynab.category_budget",
f"Category budget · {format_month(f'{start:%Y-%m}')}",
json_safe({"month": f"{start:%Y-%m}", "groups": groups}),
)
return "\n".join(lines), artifact
@tool
+2
View File
@@ -146,6 +146,8 @@ pub fn run() {
.title("Simba")
.inner_size(1200.0, 820.0)
.min_inner_size(420.0, 520.0)
// Let HTML5 drag-and-drop reach the webview (file drop in chat).
.disable_drag_drop_handler()
.build()?;
build_tray(app.handle())?;
register_hotkey(app.handle())?;
+2
View File
@@ -27,6 +27,7 @@ x-raggr-common: &raggr-common
- YNAB_SYNC_INTERVAL=${YNAB_SYNC_INTERVAL:-900}
- MEALIE_BASE_URL=${MEALIE_BASE_URL}
- MEALIE_API_TOKEN=${MEALIE_API_TOKEN}
- MEALIE_GROUP_SLUG=${MEALIE_GROUP_SLUG:-home}
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
- TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER}
@@ -40,6 +41,7 @@ x-raggr-common: &raggr-common
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
- OBSIDIAN_VAULT_NAME=${OBSIDIAN_VAULT_NAME:-}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
+43
View File
@@ -0,0 +1,43 @@
# Conversation context management
The LangGraph checkpoint retains original messages and artifacts. Each model
call constructs a bounded view; compaction never rewrites the checkpoint.
The prompt budget includes system instructions, saved memories, tool schemas
(including admin tools), message content and tool arguments. The default target
is 16,000 estimated tokens. A 4,096-token generation limit and 2,048-token safety
reserve also constrain the budget against the configured 32,768-token window.
Estimates use UTF-8 bytes divided by three, message overhead, and a fixed image
allowance. These are estimates rather than model-specific tokenizer counts.
Compare `prompt_budget.estimated_prompt_tokens` with actual `stream_request`
model-call usage and adjust the target/window for the deployed backend.
The current user request is always retained. Oversized tool results become
marked excerpts preserving their beginning and end; all tool IDs and arguments
remain. If necessary, intermediate assistant text is also shortened. If the
mandatory request and exchange structure cannot fit, the request fails with an
explicit error rather than silently dropping the question.
Recent history is selected as complete turns. Older user messages, assistant
answers and tool results are capped; earlier images become placeholders.
Incomplete tool exchanges from cancelled calls and orphan results are removed
from the prompt. A small budget is reserved for bounded question/answer excerpts
from older turns. These excerpts are not an LLM-generated rolling summary and do
not guarantee retention of every historical decision. No extra model call is
added to the request path. Saved memories use a separate 1,000-token estimated
budget, preferring recent entries without deleting omitted memories.
Obsidian reads accept `start_line` (one-based content line, excluding frontmatter)
and `max_lines`, allowing focused follow-up reads. Full artifact content remains
available to the interface and checkpoint.
Configuration:
- `CONTEXT_WINDOW_TOKENS`: smallest supported context window across backends.
- `PROMPT_BUDGET_TOKENS`: prompt target, including fixed instructions and tools.
- `RESPONSE_RESERVE_TOKENS`: maximum generated tokens and reserved answer space.
`context_budget` logs history estimates, message counts, dropped turns and excerpt
inclusion. `prompt_budget` logs fixed overhead and the complete prompt estimate.
Neither log includes message content.
+8
View File
@@ -202,3 +202,11 @@ body {
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.toggle-track.checked .toggle-thumb { transform: translateX(16px); }
@keyframes sheet-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-sheet-in {
animation: sheet-in 0.25s ease-out;
}
+36
View File
@@ -0,0 +1,36 @@
import { userService } from "./userService";
import type { Artifact, ArtifactRef } from "./artifactTypes";
/** A list entry from `GET /api/artifacts`: the ref plus where it came from. */
export type ArtifactListItem = ArtifactRef & {
tool: string;
message_id: string | null;
created_at: string;
};
class ArtifactService {
private baseUrl = "/api/artifacts";
async listArtifacts(conversationId: string): Promise<ArtifactListItem[]> {
const qs = new URLSearchParams({ conversation_id: conversationId });
const response = await userService.fetchWithAuth(`${this.baseUrl}?${qs}`);
if (!response.ok) {
throw new Error("Failed to list artifacts");
}
return await response.json();
}
async getArtifact(id: string): Promise<Artifact> {
const response = await userService.fetchWithAuth(
`${this.baseUrl}/${encodeURIComponent(id)}`,
);
if (!response.ok) {
throw new Error(
response.status === 404 ? "Artifact not found" : "Failed to load artifact",
);
}
return await response.json();
}
}
export const artifactService = new ArtifactService();
+37
View File
@@ -0,0 +1,37 @@
// Artifact types shared by the activity timeline and the artifact panel.
// Shapes follow the contract in docs/cowork-ui-plan.md.
export type ArtifactKind =
| "mealie.recipe"
| "mealie.meal_plan"
| "mealie.shopping_lists"
| "obsidian.note"
| "search.documents"
| "ynab.summary"
| "ynab.transactions"
| "ynab.spending_report"
| "ynab.category_budget";
/** Lightweight reference carried by `tool_end` events and tool messages. */
export type ArtifactRef = {
id: string;
kind: ArtifactKind;
title: string;
};
export type ArtifactSourceRef = {
system: "mealie" | "obsidian" | "paperless" | "ynab" | "web";
id?: string;
path?: string;
url?: string;
};
/** Full artifact as returned by `GET /api/artifacts/<id>`. */
export type Artifact = ArtifactRef & {
data: object;
source_ref: ArtifactSourceRef | null;
tool: string;
conversation_id: string;
message_id: string | null;
created_at: string;
};
+40 -7
View File
@@ -1,10 +1,29 @@
import { userService } from "./userService";
import type { ArtifactRef } from "./artifactTypes";
export type ToolInput = Record<string, string | number | boolean>;
export type SSEEvent =
| { type: "thinking" }
| { type: "thinking_delta"; delta: string }
| { type: "tool_start"; tool: string }
| { type: "tool_end"; tool: 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 };
@@ -17,6 +36,10 @@ interface Message {
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 {
@@ -28,6 +51,12 @@ interface Conversation {
user_id?: string;
}
export type UploadResult =
| { kind: "image"; image_key: string }
| { kind: "file"; file_key: string; name: string; chars: number };
export class UploadError extends Error {}
interface QueryResponse {
response: string;
}
@@ -159,16 +188,16 @@ class ConversationService {
return await response.json();
}
async uploadImage(
async uploadAttachment(
file: File,
conversationId: string,
): Promise<{ image_key: string }> {
): Promise<UploadResult> {
const formData = new FormData();
formData.append("file", file);
formData.append("conversation_id", conversationId);
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/upload-image`,
`${this.conversationBaseUrl}/upload`,
{
method: "POST",
body: formData,
@@ -177,8 +206,8 @@ class ConversationService {
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to upload image");
const data = await response.json().catch(() => ({}));
throw new UploadError(data.error || "Failed to upload file");
}
return await response.json();
@@ -201,11 +230,15 @@ class ConversationService {
onEvent: SSEEventCallback,
signal?: AbortSignal,
imageKey?: string,
fileKey?: string,
): Promise<void> {
const body: Record<string, string> = { query, conversation_id };
if (imageKey) {
body.image_key = imageKey;
}
if (fileKey) {
body.file_key = fileKey;
}
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/stream-query`,
@@ -0,0 +1,242 @@
import React, { useEffect, useId, useState } from "react";
import {
BookOpen,
CalendarDays,
Cat,
Check,
ChefHat,
ChevronDown,
Clock,
Globe,
Library,
LoaderCircle,
Mail,
NotebookPen,
Package,
PanelRightOpen,
PawPrint,
Utensils,
Wallet,
Wrench,
X,
type LucideIcon,
} from "lucide-react";
import { cn } from "../lib/utils";
import type { ArtifactRef } from "../api/artifactTypes";
import type { ToolInput } from "../api/conversationService";
import type { ToolMessage } from "../hooks/useChat";
// Checked in order, so longer prefixes (simbakit_) come before shorter ones.
const TOOL_ICONS: [prefix: string, icon: LucideIcon][] = [
["mealie", ChefHat],
["ynab", Wallet],
["obsidian", NotebookPen],
["journal", BookOpen],
["simbakit", PawPrint],
["simba_search", Cat],
["web_search", Globe],
["homebox", Package],
["bookstack", Library],
["gmail", Mail],
["beli", Utensils],
["get_current_date", Clock],
];
// Calendar tools are verb-first (get_calendar_events, create_calendar_event),
// so they match anywhere in the name rather than by prefix.
const iconFor = (tool: string): LucideIcon =>
tool.includes("calendar")
? CalendarDays
: (TOOL_ICONS.find(([prefix]) => tool.startsWith(prefix))?.[1] ?? Wrench);
const MAX_VALUE_CHARS = 40;
/** `month: 2026-10 · group_by: category`, with long values clipped. */
export const summariseInput = (input?: ToolInput): string =>
Object.entries(input ?? {})
.filter(([, value]) => value !== "" && value !== null && value !== undefined)
.map(([key, value]) => {
const text = String(value).replace(/\s+/g, " ");
return `${key}: ${text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}…` : text}`;
})
.join(" · ");
export const formatDuration = (ms: number): string =>
ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
/**
* Wall-clock span of the block when the client timed it (parallel calls
* overlap, so summing would overcount); otherwise the sum of server
* durations. Restored conversations have neither.
*/
const totalDuration = (rows: ToolMessage[]): number | null => {
const starts = rows.map((r) => r.startedAt);
const ends = rows.map((r) => r.endedAt);
if (starts.every((t) => t != null) && ends.every((t) => t != null)) {
return Math.max(...(ends as number[])) - Math.min(...(starts as number[]));
}
const timed = rows.filter((r) => r.ms != null);
return timed.length ? timed.reduce((sum, r) => sum + (r.ms ?? 0), 0) : null;
};
type ActivityTimelineProps = {
rows: ToolMessage[];
/** True while this block belongs to the in-flight turn. */
active: boolean;
onOpenArtifact: (ref: ArtifactRef) => void;
};
export const ActivityTimeline = React.memo(
({ rows, active, onOpenArtifact }: ActivityTimelineProps) => {
const working = active || rows.some((r) => r.status === "running");
// null = follow the turn (open while working, collapsed when done).
const [userExpanded, setUserExpanded] = useState<boolean | null>(null);
const listId = useId();
// Collapse once the turn finishes, even if the user toggled mid-run.
useEffect(() => {
if (!working) setUserExpanded(null);
}, [working]);
const expanded = userExpanded ?? working;
const failed = rows.filter((r) => r.status === "error").length;
const total = totalDuration(rows);
const summary = [
"Simba worked",
`${rows.length} ${rows.length === 1 ? "step" : "steps"}`,
total != null ? formatDuration(total) : null,
failed ? `${failed} failed` : null,
]
.filter(Boolean)
.join(" · ");
return (
<div className="flex justify-center message-enter">
<div className="w-full max-w-2xl">
<button
type="button"
onClick={() => setUserExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={listId}
className={cn(
"w-full flex items-center justify-between gap-2",
"px-3 py-1.5 rounded-xl",
"bg-leaf-pale border border-leaf-light/50",
"text-xs text-leaf-dark",
"hover:bg-leaf-light/20 transition-colors cursor-pointer",
)}
>
<span className="flex items-center gap-1.5 min-w-0">
{working ? (
<LoaderCircle size={13} className="animate-spin shrink-0" />
) : failed ? (
<X size={13} className="text-red-500 shrink-0" />
) : (
<Check size={13} className="shrink-0" />
)}
<span className={cn("truncate", working && "italic")}>
{working ? "Simba is working…" : summary}
</span>
</span>
<ChevronDown
size={13}
className={cn(
"text-leaf-dark/60 transition-transform duration-200 shrink-0",
expanded && "rotate-180",
)}
/>
</button>
{expanded && (
<ul
id={listId}
className={cn(
"mt-1 rounded-xl overflow-hidden",
"bg-warm-white border border-sand-light",
"divide-y divide-sand-light/60",
)}
>
{rows.map((row) => (
<TimelineRow
key={row.run_id}
row={row}
onOpenArtifact={onOpenArtifact}
/>
))}
</ul>
)}
</div>
</div>
);
},
);
const TimelineRow = ({
row,
onOpenArtifact,
}: {
row: ToolMessage;
onOpenArtifact: (ref: ArtifactRef) => void;
}) => {
const Icon = iconFor(row.tool);
const inputSummary = summariseInput(row.input);
const artifact = row.artifact;
return (
<li className="flex items-center gap-2.5 px-3 py-2">
<span
className="w-6 h-6 shrink-0 rounded-lg bg-leaf-pale text-leaf-dark flex items-center justify-center"
title={row.tool}
>
<Icon size={13} />
</span>
<div className="min-w-0 flex-1">
<div className="text-[13px] text-charcoal truncate">{row.label}</div>
{inputSummary && (
<div
className="text-[11px] text-warm-gray font-mono truncate"
title={inputSummary}
>
{inputSummary}
</div>
)}
</div>
{artifact && (
<button
type="button"
onClick={() => onOpenArtifact(artifact)}
title={artifact.title}
aria-label={`Open ${artifact.title}`}
className={cn(
"shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full",
"bg-amber-pale border border-amber-soft/70 text-[11px] font-medium text-amber-dark",
"hover:bg-amber-soft/40 transition-colors cursor-pointer",
)}
>
<PanelRightOpen size={11} />
Open
</button>
)}
<span className="shrink-0 flex items-center gap-1.5 text-[11px] text-warm-gray tabular-nums">
{row.ms != null && row.status !== "running" && (
<span>{formatDuration(row.ms)}</span>
)}
{row.status === "running" ? (
<LoaderCircle
size={14}
className="animate-spin text-leaf"
aria-label="Running"
/>
) : row.status === "error" ? (
<X size={14} className="text-red-500" aria-label="Failed" />
) : (
<Check size={14} className="text-leaf" aria-label="Done" />
)}
</span>
</li>
);
};
+127 -27
View File
@@ -1,17 +1,21 @@
import React, { useCallback, useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { PanelLeftOpen, Menu, X } from "lucide-react";
import { useMatch, useNavigate } from "react-router-dom";
import { Library, PanelLeftOpen, Menu, X } from "lucide-react";
import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble";
import { ToolBubble } from "./ToolBubble";
import { ActivityTimeline } from "./ActivityTimeline";
import { ThinkingBubble } from "./ThinkingBubble";
import { MessageInput } from "./MessageInput";
import { DropOverlay } from "./DropOverlay";
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 { cn } from "../lib/utils";
import { useConversations } from "../hooks/useConversations";
import { useChat } from "../hooks/useChat";
import { useChat, type ToolMessage } from "../hooks/useChat";
import type { ArtifactRef } from "../api/artifactTypes";
import catIcon from "../assets/cat.png";
type ChatScreenProps = {
@@ -50,14 +54,42 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
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;
// 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 {
messages,
isLoading,
streaming,
thinking,
messagesLoading,
pendingImage,
setPendingImage,
pendingAttachment,
attachFile,
clearAttachment,
attachmentError,
clearAttachmentError,
sendMessage,
stopGeneration,
error,
@@ -71,6 +103,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
refreshConversations,
onSessionExpired,
scrollToBottom,
onArtifact: handleArtifact,
});
// Keep ref in sync for scrollToBottom behavior
@@ -112,9 +145,6 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
[deleteConversation],
);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
const handleLogout = () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
@@ -194,7 +224,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
<div className="relative flex-1 flex flex-col h-full overflow-hidden min-w-0">
<DropOverlay onDrop={attachFile} />
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
<div className="flex items-center gap-2">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
@@ -205,15 +236,52 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
asksimba
</h1>
</div>
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={() => setShowConversations(true)}
aria-label="Open menu"
>
<Menu size={16} />
</button>
<div className="flex items-center gap-1">
{conversationId && (
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={toggleArtifacts}
aria-label="Artifacts"
>
<Library size={16} />
</button>
)}
<button
className="w-8 h-8 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer"
onClick={() => setShowConversations(true)}
aria-label="Open menu"
>
<Menu size={16} />
</button>
</div>
</header>
{conversationId && (
<header className="hidden md:flex items-center justify-between gap-3 px-4 py-2 border-b border-sand-light/40">
<h2 className="min-w-0 truncate text-sm font-semibold text-warm-gray">
{selectedConversation?.title}
</h2>
<button
onClick={toggleArtifacts}
aria-pressed={artifactPanel.isOpen}
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold transition-all cursor-pointer",
artifactPanel.isOpen
? "bg-leaf-pale text-leaf-dark"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<Library size={14} />
Artifacts
{artifactPanel.tabs.length > 0 && (
<span className="rounded-full bg-leaf/15 px-1.5 text-[10px] text-leaf-dark">
{artifactPanel.tabs.length}
</span>
)}
</button>
</header>
)}
{messages.length === 0 && !messagesLoading ? (
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
<div className="relative">
@@ -236,9 +304,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
onToggleSimbaMode={handleToggleSimbaMode}
isLoading={isLoading}
onStop={stopGeneration}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
pendingAttachment={pendingAttachment}
onAttach={attachFile}
onClearAttachment={clearAttachment}
attachmentError={attachmentError}
onDismissAttachmentError={clearAttachmentError}
autoFocus
/>
</div>
@@ -248,6 +318,29 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-3xl mx-auto flex flex-col gap-6">
{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 (
<ActivityTimeline
key={`tools-${rows[0].run_id}`}
rows={rows}
// The trailing block of an in-flight turn stays
// "working" until the answer starts streaming.
active={isLoading && end === messages.length}
onOpenArtifact={openArtifact}
/>
);
}
// 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.
@@ -259,9 +352,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
msg.speaker === "simba";
const bubble =
msg.speaker === "tool" ? (
<ToolBubble key={index} text={msg.text} />
) : msg.speaker === "simba" ? (
msg.speaker === "simba" ? (
<AnswerBubble key={index} text={msg.text} />
) : (
<QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />
@@ -291,7 +382,12 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
/>
)}
{((isLoading && !streaming && !thinking) || messagesLoading) && (
{/* Placeholder dots, unless the activity timeline already shows progress */}
{((isLoading &&
!streaming &&
!thinking &&
messages[messages.length - 1]?.speaker !== "tool") ||
messagesLoading) && (
<AnswerBubble text="" loading={true} />
)}
@@ -339,15 +435,19 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
onToggleSimbaMode={handleToggleSimbaMode}
isLoading={isLoading}
onStop={stopGeneration}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
pendingAttachment={pendingAttachment}
onAttach={attachFile}
onClearAttachment={clearAttachment}
attachmentError={attachmentError}
onDismissAttachmentError={clearAttachmentError}
/>
</div>
</footer>
</>
)}
</div>
<ArtifactPanel panel={artifactPanel} conversationId={conversationId} />
</div>
);
};
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import { Paperclip } from "lucide-react";
const hasFiles = (e: DragEvent) =>
Array.from(e.dataTransfer?.types ?? []).includes("Files");
type DropOverlayProps = {
onDrop: (file: File) => void;
};
/**
* Listens for file drags anywhere in the window and covers its positioned
* parent (the chat column, beside the artifact panel) with a "Drop to attach"
* target. Below lg the panel is a full-screen sheet, so it covers the window
* and sits above the sheet. Drags without files (text, links)
* are ignored so they keep their normal behaviour.
*/
export const DropOverlay = ({ onDrop }: DropOverlayProps) => {
const [active, setActive] = useState(false);
// dragenter/dragleave fire for every element crossed; count to know when
// the drag has really left the window.
const depthRef = useRef(0);
const onDropRef = useRef(onDrop);
onDropRef.current = onDrop;
useEffect(() => {
const handleEnter = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depthRef.current += 1;
setActive(true);
};
const handleOver = (e: DragEvent) => {
if (!hasFiles(e)) return;
// Required for the drop event to fire (and to stop the browser
// navigating to the file).
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
};
const handleLeave = (e: DragEvent) => {
if (!hasFiles(e)) return;
depthRef.current = Math.max(0, depthRef.current - 1);
if (depthRef.current === 0) setActive(false);
};
const handleDrop = (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
depthRef.current = 0;
setActive(false);
const file = e.dataTransfer?.files?.[0];
if (file) onDropRef.current(file);
};
window.addEventListener("dragenter", handleEnter);
window.addEventListener("dragover", handleOver);
window.addEventListener("dragleave", handleLeave);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleEnter);
window.removeEventListener("dragover", handleOver);
window.removeEventListener("dragleave", handleLeave);
window.removeEventListener("drop", handleDrop);
};
}, []);
if (!active) return null;
return (
<div
className="pointer-events-none fixed lg:absolute inset-0 z-[45] flex items-center justify-center bg-cream/80 backdrop-blur-sm p-3 md:p-5"
data-testid="drop-overlay"
>
<div className="w-full h-full rounded-3xl border-2 border-dashed border-amber-glow/50 bg-amber-pale/70 flex flex-col items-center justify-center gap-3 message-enter">
<div className="w-14 h-14 rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30 text-amber-glow flex items-center justify-center">
<Paperclip size={24} />
</div>
<p
className="text-lg font-bold text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
Drop to attach
</p>
<p className="text-xs text-warm-gray">Images, PDF, TXT, MD or CSV · up to 10 MB</p>
</div>
</div>
);
};
+87 -16
View File
@@ -1,6 +1,12 @@
import React, { useEffect, useMemo, useRef } from "react";
import { ArrowUp, ImagePlus, Square, X } from "lucide-react";
import { ArrowUp, FileSpreadsheet, FileText, Paperclip, Square, X } from "lucide-react";
import { cn } from "../lib/utils";
import {
ATTACHMENT_ACCEPT,
attachmentKind,
fileExtension,
formatBytes,
} from "../lib/attachments";
import { Textarea } from "./ui/textarea";
type MessageInputProps = {
@@ -12,9 +18,11 @@ type MessageInputProps = {
query: string;
isLoading: boolean;
onStop: () => void;
pendingImage: File | null;
onImageSelect: (file: File) => void;
onClearImage: () => void;
pendingAttachment: File | null;
onAttach: (file: File) => void;
onClearAttachment: () => void;
attachmentError: string | null;
onDismissAttachmentError: () => void;
autoFocus?: boolean;
};
@@ -28,15 +36,23 @@ export const MessageInput = React.memo(
onToggleSimbaMode,
isLoading,
onStop,
pendingImage,
onImageSelect,
onClearImage,
pendingAttachment,
onAttach,
onClearAttachment,
attachmentError,
onDismissAttachmentError,
autoFocus,
}: MessageInputProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Create blob URL once per file, revoke on cleanup
const pendingImage =
pendingAttachment && attachmentKind(pendingAttachment) === "image"
? pendingAttachment
: null;
const pendingFile = pendingAttachment && !pendingImage ? pendingAttachment : null;
// Create blob URL once per image, revoke on cleanup
const previewUrl = useMemo(
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
[pendingImage],
@@ -59,7 +75,7 @@ export const MessageInput = React.memo(
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onImageSelect(file);
onAttach(file);
}
// Reset so the same file can be re-selected
e.target.value = "";
@@ -69,19 +85,21 @@ export const MessageInput = React.memo(
if (isLoading) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
e.preventDefault();
onImageSelect(file);
onAttach(file);
return;
}
}
}
};
const canSend = !isLoading && Boolean(query.trim() || pendingImage);
const canSend = !isLoading && Boolean(query.trim() || pendingAttachment);
const FileIcon =
pendingFile && fileExtension(pendingFile.name) === "csv" ? FileSpreadsheet : FileText;
return (
<div
@@ -102,7 +120,8 @@ export const MessageInput = React.memo(
/>
<button
type="button"
onClick={onClearImage}
onClick={onClearAttachment}
aria-label="Remove attachment"
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-charcoal text-white flex items-center justify-center hover:bg-charcoal/80 transition-colors cursor-pointer"
>
<X size={12} />
@@ -111,6 +130,56 @@ export const MessageInput = React.memo(
</div>
)}
{/* File chip */}
{pendingFile && (
<div className="px-3 pt-3">
<div
className="relative inline-flex items-center gap-2.5 max-w-full rounded-xl border border-sand bg-cream pl-2 pr-7 py-2 message-enter"
data-testid="attachment-chip"
>
<div className="w-8 h-8 shrink-0 rounded-lg bg-amber-pale text-amber-dark flex items-center justify-center">
<FileIcon size={16} />
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-charcoal truncate max-w-[14rem] sm:max-w-xs">
{pendingFile.name}
</div>
<div className="text-[11px] text-warm-gray">
{fileExtension(pendingFile.name).toUpperCase() || "File"} ·{" "}
{formatBytes(pendingFile.size)}
</div>
</div>
<button
type="button"
onClick={onClearAttachment}
aria-label="Remove attachment"
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full text-warm-gray flex items-center justify-center hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
>
<X size={12} />
</button>
</div>
</div>
)}
{attachmentError && (
<div className="px-3 pt-3">
<div
role="alert"
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600 message-enter"
>
<span>{attachmentError}</span>
<button
type="button"
onClick={onDismissAttachmentError}
className="text-red-400 hover:text-red-600 transition-colors cursor-pointer"
aria-label="Dismiss error"
>
<X size={13} />
</button>
</div>
</div>
)}
{/* Textarea */}
<Textarea
ref={textareaRef}
@@ -128,7 +197,7 @@ export const MessageInput = React.memo(
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept={ATTACHMENT_ACCEPT}
onChange={handleFileChange}
className="hidden"
/>
@@ -150,11 +219,13 @@ export const MessageInput = React.memo(
</span>
</button>
{/* Image attach button */}
{/* Attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
aria-label="Attach a file"
title="Attach an image, PDF, TXT, MD or CSV"
className={cn(
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
isLoading
@@ -162,7 +233,7 @@ export const MessageInput = React.memo(
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<ImagePlus size={16} />
<Paperclip size={16} />
</button>
</div>
@@ -1,5 +1,7 @@
import React from "react";
import { FileSpreadsheet, FileText } from "lucide-react";
import { cn } from "../lib/utils";
import { fileExtension, splitAttachedFile } from "../lib/attachments";
import { usePresignedUrl } from "../hooks/usePresignedUrl";
type QuestionBubbleProps = {
@@ -9,6 +11,8 @@ type QuestionBubbleProps = {
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
const { imageUrl, imageError } = usePresignedUrl(image_key);
const { body, fileName } = splitAttachedFile(text);
const FileIcon = fileName && fileExtension(fileName) === "csv" ? FileSpreadsheet : FileText;
return (
<div className="flex justify-end message-enter">
@@ -32,7 +36,21 @@ export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubblePro
className="max-w-full rounded-xl mb-2"
/>
)}
{text}
{fileName && (
<div
className={cn(
"flex items-center gap-1.5 w-fit max-w-full rounded-lg",
"bg-warm-white/70 border border-leaf-light/70 px-2 py-1",
"text-xs text-leaf-dark",
body && "mb-2",
)}
data-testid="message-attachment"
>
<FileIcon size={13} className="shrink-0" />
<span className="truncate">{fileName}</span>
</div>
)}
{body}
</div>
</div>
);
@@ -1,19 +0,0 @@
import React from "react";
import { cn } from "../lib/utils";
export const ToolBubble = React.memo(
({ text, pulse = false }: { text: string; pulse?: boolean }) => (
<div className="flex justify-center message-enter">
<div
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full",
"bg-leaf-pale border border-leaf-light/50",
"text-xs text-leaf-dark italic",
pulse && "animate-pulse",
)}
>
{text}
</div>
</div>
),
);
@@ -0,0 +1,384 @@
import { type ReactNode, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import {
Library,
LoaderCircle,
PanelRightClose,
PanelRightOpen,
RotateCw,
TriangleAlert,
X,
} from "lucide-react";
import { artifactService, type ArtifactListItem } from "../../api/artifactService";
import type { ArtifactRef } from "../../api/artifactTypes";
import { cn } from "../../lib/utils";
import { ArtifactRenderer, kindIcon } from "./renderers";
import { formatDate } from "./renderers/format";
import type { ArtifactPanelState } from "./useArtifactPanel";
const DESKTOP_QUERY = "(min-width: 1024px)"; // Tailwind `lg`
function useMediaQuery(query: string): boolean {
const subscribe = useCallback(
(onChange: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
[query],
);
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches);
}
const iconButton =
"w-8 h-8 shrink-0 rounded-xl flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-all cursor-pointer";
type ArtifactPanelProps = {
panel: ArtifactPanelState;
conversationId: string | undefined;
};
/**
* Right-hand artifact pane (lg and up) or bottom sheet (below lg). Renders
* nothing until something opens it.
*/
export const ArtifactPanel = ({ panel, conversationId }: ArtifactPanelProps) => {
const isDesktop = useMediaQuery(DESKTOP_QUERY);
const { isOpen, tabs } = panel;
// Escape closes the mobile sheet.
useEffect(() => {
if (isDesktop || !isOpen) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && panel.collapse();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isDesktop, isOpen, panel.collapse]);
if (isDesktop) {
if (!isOpen) {
if (tabs.length === 0) return null;
return <CollapsedRail panel={panel} />;
}
return (
<aside
className="w-[440px] shrink-0 flex flex-col h-full border-l border-sand-light/70 bg-warm-white"
aria-label="Artifacts"
>
<PanelHeader panel={panel} closeIcon={<PanelRightClose size={16} />} closeLabel="Collapse panel" />
<PanelBody panel={panel} conversationId={conversationId} />
</aside>
);
}
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-40 flex flex-col justify-end" role="dialog" aria-label="Artifacts">
<div className="absolute inset-0 bg-charcoal/40 backdrop-blur-sm" onClick={panel.collapse} />
<div className="relative flex h-[85dvh] flex-col overflow-hidden rounded-t-2xl bg-warm-white shadow-2xl animate-sheet-in">
<div className="flex justify-center pt-2">
<span className="h-1 w-10 rounded-full bg-sand" />
</div>
<PanelHeader panel={panel} closeIcon={<X size={16} />} closeLabel="Close artifacts" />
<PanelBody panel={panel} conversationId={conversationId} />
</div>
</div>
);
};
const CollapsedRail = ({ panel }: { panel: ArtifactPanelState }) => (
<aside className="w-12 shrink-0 flex flex-col items-center gap-2 py-3 h-full border-l border-sand-light/70 bg-warm-white">
<button onClick={panel.expand} className={iconButton} aria-label="Expand artifacts panel">
<PanelRightOpen size={16} />
</button>
<div className="h-px w-6 bg-sand-light" />
{panel.tabs.map((tab) => {
const Icon = kindIcon(tab.kind);
return (
<button
key={tab.id}
onClick={() => panel.open(tab)}
title={tab.title}
aria-label={`Show ${tab.title}`}
className={cn(iconButton, panel.active?.id === tab.id && "text-leaf-dark bg-leaf-pale")}
>
<Icon size={15} />
</button>
);
})}
</aside>
);
const PanelHeader = ({
panel,
closeIcon,
closeLabel,
}: {
panel: ArtifactPanelState;
closeIcon: ReactNode;
closeLabel: string;
}) => {
const libraryActive = panel.view === "library" || !panel.active;
const stripRef = useRef<HTMLDivElement>(null);
// Keep the active tab visible when the strip overflows.
const activeId = panel.active?.id;
useEffect(() => {
// The wrapper, so the tab's close button is in view too.
stripRef.current
?.querySelector('[aria-selected="true"]')
?.parentElement?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [activeId, libraryActive, panel.tabs.length]);
return (
<div className="flex items-center gap-1 border-b border-sand-light/70 px-2 py-2">
<div
ref={stripRef}
className="flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none]"
role="tablist"
>
{panel.tabs.length === 0 && (
<span className="px-2 text-sm font-bold text-charcoal" style={{ fontFamily: "var(--font-display)" }}>
Artifacts
</span>
)}
{panel.tabs.map((tab) => (
<Tab
key={tab.id}
tab={tab}
selected={!libraryActive && panel.active?.id === tab.id}
onSelect={() => panel.open(tab)}
onClose={() => panel.close(tab.id)}
/>
))}
</div>
<button
onClick={() => (libraryActive && panel.active ? panel.setView("tab") : panel.showLibrary())}
className={cn(iconButton, libraryActive && "text-leaf-dark bg-leaf-pale")}
aria-label="All artifacts in this conversation"
aria-pressed={libraryActive}
title="All artifacts"
>
<Library size={16} />
</button>
<button onClick={panel.collapse} className={iconButton} aria-label={closeLabel} title={closeLabel}>
{closeIcon}
</button>
</div>
);
};
const Tab = ({
tab,
selected,
onSelect,
onClose,
}: {
tab: ArtifactRef;
selected: boolean;
onSelect: () => void;
onClose: () => void;
}) => {
const Icon = kindIcon(tab.kind);
return (
<div
className={cn(
"group flex min-w-[96px] max-w-[200px] shrink items-center rounded-lg border text-xs transition-colors",
selected
? "border-leaf-light bg-leaf-pale text-leaf-dark"
: "border-transparent text-warm-gray hover:bg-cream-dark hover:text-charcoal",
)}
>
<button
role="tab"
aria-selected={selected}
onClick={onSelect}
title={tab.title}
className="flex min-w-0 flex-1 items-center gap-1.5 py-1 pl-2 pr-1 font-semibold cursor-pointer"
>
<Icon size={13} className="shrink-0" />
<span className="truncate">{tab.title}</span>
</button>
<button
onClick={onClose}
aria-label={`Close ${tab.title}`}
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md opacity-60 hover:bg-sand-light hover:opacity-100 cursor-pointer"
>
<X size={11} />
</button>
</div>
);
};
const PanelBody = ({
panel,
conversationId,
}: {
panel: ArtifactPanelState;
conversationId: string | undefined;
}) => {
const { active, activeEntry } = panel;
if (panel.view === "library" || !active || !activeEntry) {
return (
<div className="flex-1 overflow-y-auto px-4 py-4">
<ArtifactLibrary conversationId={conversationId} panel={panel} />
</div>
);
}
return (
<div className="flex-1 overflow-y-auto px-4 py-4">
{activeEntry.status === "loading" && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-warm-gray">
<LoaderCircle size={22} className="animate-spin text-leaf" />
<span className="text-sm">Loading {active.title}…</span>
</div>
)}
{activeEntry.status === "error" && (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<TriangleAlert size={22} className="text-amber-dark" />
<div>
<p className="text-sm font-semibold text-charcoal">Couldn&apos;t load this artifact</p>
<p className="text-xs text-warm-gray">{activeEntry.error}</p>
</div>
<button
onClick={() => panel.retry(active.id)}
className="inline-flex items-center gap-1.5 rounded-xl border border-sand px-3 py-1.5 text-xs font-semibold text-warm-gray hover:bg-cream-dark hover:text-charcoal cursor-pointer"
>
<RotateCw size={12} /> Retry
</button>
</div>
)}
{activeEntry.status === "ready" && (
<article key={active.id} className="message-enter">
<h2
className="mb-3 text-lg font-bold leading-snug text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
{activeEntry.artifact.title}
</h2>
<ArtifactRenderer artifact={activeEntry.artifact} />
</article>
)}
</div>
);
};
type LibraryState =
| { status: "loading" }
| { status: "error" }
| { status: "ready"; items: ArtifactListItem[] };
/** Every artifact in the conversation, newest first, so earlier ones can be reopened. */
const ArtifactLibrary = ({
conversationId,
panel,
}: {
conversationId: string | undefined;
panel: ArtifactPanelState;
}) => {
const [state, setState] = useState<LibraryState>({ status: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!conversationId) {
setState({ status: "ready", items: [] });
return;
}
let cancelled = false;
setState({ status: "loading" });
artifactService
.listArtifacts(conversationId)
.then((items) => {
if (cancelled) return;
const sorted = [...items].sort((a, b) => b.created_at.localeCompare(a.created_at));
setState({ status: "ready", items: sorted });
})
.catch((err) => {
console.error("Failed to list artifacts:", err);
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [conversationId, attempt]);
if (state.status === "loading") {
return (
<ul className="flex flex-col gap-2" aria-busy="true">
{[0, 1, 2].map((i) => (
<li key={i} className="h-14 rounded-xl bg-sand-light/50 animate-pulse" />
))}
</ul>
);
}
if (state.status === "error") {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<TriangleAlert size={22} className="text-amber-dark" />
<p className="text-sm text-charcoal">Couldn&apos;t load this conversation&apos;s artifacts.</p>
<button
onClick={() => setAttempt((n) => n + 1)}
className="inline-flex items-center gap-1.5 rounded-xl border border-sand px-3 py-1.5 text-xs font-semibold text-warm-gray hover:bg-cream-dark hover:text-charcoal cursor-pointer"
>
<RotateCw size={12} /> Retry
</button>
</div>
);
}
if (state.items.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-amber-pale text-amber-dark">
<Library size={20} />
</div>
<p className="text-sm font-semibold text-charcoal">No artifacts yet</p>
<p className="max-w-[260px] text-xs leading-relaxed text-warm-gray">
When Simba pulls up a recipe, a budget, a note or some documents, it shows up here.
</p>
</div>
);
}
const openIds = new Set(panel.tabs.map((t) => t.id));
return (
<div className="flex flex-col gap-2">
<p className="text-[11px] font-bold uppercase tracking-wider text-warm-gray">
In this conversation · {state.items.length}
</p>
<ul className="flex flex-col gap-1.5">
{state.items.map((item) => {
const Icon = kindIcon(item.kind);
return (
<li key={item.id}>
<button
onClick={() => panel.open({ id: item.id, kind: item.kind, title: item.title })}
className="flex w-full items-center gap-3 rounded-xl border border-sand-light bg-warm-white px-3 py-2.5 text-left hover:border-leaf-light hover:bg-leaf-pale/50 transition-colors cursor-pointer"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-cream-dark text-leaf-dark">
<Icon size={16} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-charcoal">{item.title}</span>
<span className="block truncate text-[11px] text-warm-gray">
{formatDate(item.created_at, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
{" · "}
{item.tool}
</span>
</span>
{openIds.has(item.id) && (
<span className="shrink-0 text-[10px] font-bold uppercase tracking-wide text-leaf-dark">Open</span>
)}
</button>
</li>
);
})}
</ul>
</div>
);
};
@@ -0,0 +1,63 @@
import { formatDate, formatRange } from "./format";
import { EmptyNote, Subtle } from "./shared";
import type { MealieMealPlanData, RendererProps } from "./types";
const todayIso = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
export const MealieMealPlan = ({ data }: RendererProps<MealieMealPlanData>) => {
const days = [...(data.days ?? [])].sort((a, b) => a.date.localeCompare(b.date));
const today = todayIso();
return (
<div className="flex flex-col gap-3">
<Subtle>{formatRange(data.start_date, data.end_date)}</Subtle>
{days.length === 0 ? (
<EmptyNote>Nothing planned.</EmptyNote>
) : (
<ol className="flex flex-col gap-2">
{days.map((day) => (
<li
key={day.date}
className={
day.date === today
? "rounded-xl border border-leaf-light bg-leaf-pale/60 px-3 py-2.5"
: "rounded-xl border border-sand-light bg-warm-white px-3 py-2.5"
}
>
<div className="mb-1.5 flex items-baseline gap-2">
<span className="text-sm font-bold text-charcoal">
{formatDate(day.date, { weekday: "long" })}
</span>
<span className="text-xs text-warm-gray">
{formatDate(day.date, { month: "short", day: "numeric" })}
</span>
{day.date === today && (
<span className="text-[10px] font-bold uppercase tracking-wide text-leaf-dark">Today</span>
)}
</div>
{day.meals.length === 0 ? (
<p className="text-xs italic text-warm-gray">Nothing planned</p>
) : (
<ul className="flex flex-col gap-1">
{day.meals.map((meal, i) => (
<li key={i} className="flex items-baseline gap-2 text-sm">
<span className="w-[4.5rem] shrink-0 text-[11px] font-semibold uppercase tracking-wide text-amber-dark">
{meal.entry_type}
</span>
<span className="min-w-0">
<span className="text-charcoal">{meal.name}</span>
{meal.note && <span className="block text-xs text-warm-gray">{meal.note}</span>}
</span>
</li>
))}
</ul>
)}
</li>
))}
</ol>
)}
</div>
);
};
@@ -0,0 +1,116 @@
import { useState } from "react";
import { Clock, Users } from "lucide-react";
import { cn } from "../../../lib/utils";
import { Chip, OpenExternal, SectionTitle } from "./shared";
import type { MealieRecipeData, RendererProps } from "./types";
export const MealieRecipe = ({ data, sourceRef }: RendererProps<MealieRecipeData>) => {
const [ticked, setTicked] = useState<Set<number>>(new Set());
const [imageFailed, setImageFailed] = useState(false);
const ingredients = data.ingredients ?? [];
const instructions = data.instructions ?? [];
const toggle = (i: number) =>
setTicked((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
const times = [
["Prep", data.prep_time],
["Cook", data.cook_time],
["Total", data.total_time],
].filter(([, v]) => v) as [string, string][];
return (
<div className="flex flex-col gap-5">
<header className="flex flex-col gap-3">
{data.image_url && !imageFailed && (
<img
src={data.image_url}
alt={data.name}
onError={() => setImageFailed(true)}
className="aspect-[16/9] w-full rounded-xl object-cover border border-sand-light"
/>
)}
{/* The panel already titles the artifact with the recipe name. */}
{data.description && <p className="text-sm leading-relaxed text-warm-gray">{data.description}</p>}
<div className="flex flex-wrap gap-1.5">
{times.map(([label, value]) => (
<Chip key={label} icon={<Clock size={12} />}>
{label} {value}
</Chip>
))}
{data.servings != null && data.servings !== "" && (
<Chip icon={<Users size={12} />}>Serves {data.servings}</Chip>
)}
</div>
{(data.tags?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-1.5">
{data.tags.map((t) => (
<Chip key={t} tone="leaf">
{t}
</Chip>
))}
</div>
)}
{sourceRef?.url && (
<div>
<OpenExternal href={sourceRef.url} label="Open in Mealie" />
</div>
)}
</header>
{ingredients.length > 0 && (
<section>
<SectionTitle>
Ingredients
{ticked.size > 0 && (
<span className="ml-1.5 normal-case tracking-normal font-medium">
· {ticked.size}/{ingredients.length}
</span>
)}
</SectionTitle>
<ul className="flex flex-col gap-0.5">
{ingredients.map((ing, i) => (
<li key={i}>
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg px-2 py-1.5 text-sm hover:bg-cream-dark/60">
<input
type="checkbox"
checked={ticked.has(i)}
onChange={() => toggle(i)}
className="mt-0.5 h-4 w-4 shrink-0 accent-leaf cursor-pointer"
/>
<span className={cn(ticked.has(i) ? "text-warm-gray line-through" : "text-charcoal")}>
{ing}
</span>
</label>
</li>
))}
</ul>
</section>
)}
{instructions.length > 0 && (
<section>
<SectionTitle>Steps</SectionTitle>
<ol className="flex flex-col gap-3">
{instructions.map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-amber-pale text-xs font-bold text-amber-dark">
{i + 1}
</span>
<div className="pt-0.5 text-sm leading-relaxed text-charcoal">
{step.title && <div className="font-semibold">{step.title}</div>}
{step.text}
</div>
</li>
))}
</ol>
</section>
)}
</div>
);
};
@@ -0,0 +1,51 @@
import { Check } from "lucide-react";
import { cn } from "../../../lib/utils";
import { EmptyNote } from "./shared";
import type { MealieShoppingListsData, RendererProps } from "./types";
export const MealieShoppingLists = ({ data }: RendererProps<MealieShoppingListsData>) => {
const lists = data.lists ?? [];
if (lists.length === 0) return <EmptyNote>No shopping lists.</EmptyNote>;
return (
<div className="flex flex-col gap-4">
{lists.map((list) => {
const done = list.items.filter((i) => i.checked).length;
return (
<section key={list.name} className="rounded-xl border border-sand-light bg-warm-white">
<header className="flex items-baseline justify-between border-b border-sand-light px-3 py-2">
<h3 className="text-sm font-bold text-charcoal">{list.name}</h3>
<span className="text-xs text-warm-gray">
{done}/{list.items.length} done
</span>
</header>
{list.items.length === 0 ? (
<p className="px-3 py-3 text-xs italic text-warm-gray">Empty list</p>
) : (
<ul className="divide-y divide-sand-light/60">
{list.items.map((item, i) => (
<li key={i} className="flex items-center gap-2.5 px-3 py-1.5 text-sm">
<span
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border",
item.checked ? "border-leaf bg-leaf text-white" : "border-sand",
)}
>
{item.checked && <Check size={11} strokeWidth={3} />}
</span>
<span className={cn("flex-1", item.checked ? "text-warm-gray line-through" : "text-charcoal")}>
{item.name}
</span>
{/* Like the tool's text, quantities of 0/1 aren't worth showing. */}
{item.quantity != null && item.quantity !== "" && Number(item.quantity) !== 0 && Number(item.quantity) !== 1 && (
<span className="text-xs tabular-nums text-warm-gray">{item.quantity}</span>
)}
</li>
))}
</ul>
)}
</section>
);
})}
</div>
);
};
@@ -0,0 +1,30 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Chip, OpenExternal, Subtle } from "./shared";
import type { ObsidianNoteData, RendererProps } from "./types";
export const ObsidianNote = ({ data, sourceRef }: RendererProps<ObsidianNoteData>) => (
<div className="flex flex-col gap-4">
<header className="flex flex-col gap-2">
<Subtle className="font-mono break-all">{data.path}</Subtle>
{(data.tags?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-1.5">
{data.tags.map((t) => (
<Chip key={t} tone="leaf">
#{t}
</Chip>
))}
</div>
)}
{sourceRef?.url && (
<div>
<OpenExternal href={sourceRef.url} label="Open in Obsidian" />
</div>
)}
</header>
{/* Tailwind's preflight strips list markers; notes need them back. */}
<div className="markdown-content text-[15px] leading-relaxed text-charcoal [&_ul]:list-disc [&_ol]:list-decimal [&_ul.contains-task-list]:list-none [&_ul.contains-task-list]:pl-1">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.markdown ?? ""}</ReactMarkdown>
</div>
</div>
);
@@ -0,0 +1,48 @@
import { FileText } from "lucide-react";
import { formatDate } from "./format";
import { EmptyNote, Subtle } from "./shared";
import type { RendererProps, SearchDocumentsData } from "./types";
export const SearchDocuments = ({ data }: RendererProps<SearchDocumentsData>) => {
const results = data.results ?? [];
return (
<div className="flex flex-col gap-3">
<Subtle>
“{data.query}” in {data.source} · {results.length} result{results.length === 1 ? "" : "s"}
</Subtle>
{results.length === 0 ? (
<EmptyNote>No matching documents.</EmptyNote>
) : (
<ul className="flex flex-col gap-2">
{results.map((r, i) => {
const location = r.filename ?? r.path;
return (
<li key={i} className="rounded-xl border border-sand-light bg-warm-white px-3 py-2.5">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-bold leading-snug text-charcoal">{r.title}</h4>
{r.document_type && (
<span className="shrink-0 rounded-full bg-amber-pale px-2 py-0.5 text-[10px] font-semibold text-amber-dark">
{r.document_type}
</span>
)}
</div>
<p className="mt-1 text-[13px] leading-relaxed text-charcoal/80 line-clamp-4">{r.snippet}</p>
{(location || r.date) && (
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-warm-gray">
{location && (
<span className="flex min-w-0 items-center gap-1">
<FileText size={11} className="shrink-0" />
<span className="truncate font-mono">{location}</span>
</span>
)}
{r.date && <span className="ml-auto shrink-0">{formatDate(r.date)}</span>}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
};
@@ -0,0 +1,12 @@
import { Subtle } from "./shared";
export const UnknownArtifact = ({ kind, data }: { kind: string; data: unknown }) => (
<div className="flex flex-col gap-2">
<Subtle>
No viewer for <code className="font-mono">{kind}</code> yet; showing the raw data.
</Subtle>
<pre className="overflow-x-auto rounded-xl bg-charcoal p-3 text-xs leading-relaxed text-cream-dark">
{JSON.stringify(data, null, 2)}
</pre>
</div>
);
@@ -0,0 +1,83 @@
import { cn } from "../../../lib/utils";
import { formatMoney, formatMonth, moneyTone } from "./format";
import { EmptyNote } from "./shared";
import type { RendererProps, YnabCategoryBudgetData } from "./types";
type Category = YnabCategoryBudgetData["groups"][number]["categories"][number];
/** Available as a share of assigned; full when nothing was assigned but money is available. */
function availableShare(c: Category): number {
if (c.available <= 0) return 0;
if (c.assigned <= 0) return 1;
return Math.min(1, c.available / c.assigned);
}
const sum = (cats: Category[], key: "assigned" | "activity" | "available") =>
cats.reduce((acc, c) => acc + (c[key] ?? 0), 0);
export const YnabCategoryBudget = ({ data }: RendererProps<YnabCategoryBudgetData>) => {
const groups = (data.groups ?? []).filter((g) => g.categories?.length);
if (groups.length === 0) return <EmptyNote>No categories for this month.</EmptyNote>;
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-warm-gray">{formatMonth(data.month)}</p>
<div className="overflow-x-auto rounded-xl border border-sand-light bg-warm-white">
<table className="w-full text-xs tabular-nums">
<thead>
<tr className="border-b border-sand-light text-[11px] uppercase tracking-wide text-warm-gray">
<th className="px-3 py-2 text-left font-semibold">Category</th>
<th className="px-2 py-2 text-right font-semibold">Assigned</th>
<th className="px-2 py-2 text-right font-semibold">Activity</th>
<th className="px-3 py-2 text-right font-semibold">Available</th>
</tr>
</thead>
{groups.map((group) => (
<tbody key={group.name}>
<tr className="bg-sand-light/50 font-bold text-charcoal">
<td className="px-3 py-1.5">{group.name}</td>
<td className="px-2 py-1.5 text-right">{formatMoney(sum(group.categories, "assigned"))}</td>
<td className="px-2 py-1.5 text-right">{formatMoney(sum(group.categories, "activity"), { signed: true })}</td>
<td className={cn("px-3 py-1.5 text-right", moneyTone(sum(group.categories, "available")))}>
{formatMoney(sum(group.categories, "available"))}
</td>
</tr>
{group.categories.map((c) => {
const overspent = c.available < 0;
return (
<tr
key={c.name}
className={cn("border-t border-sand-light/60", overspent && "bg-red-50")}
>
<td className="px-3 py-1.5 align-top">
<div className={cn("truncate max-w-[150px]", overspent ? "text-red-700 font-semibold" : "text-charcoal")}>
{c.name}
</div>
<div className="mt-1 h-1.5 w-full max-w-[150px] rounded-full bg-sand-light overflow-hidden">
<div
className={cn(
"h-full rounded-full",
overspent ? "bg-red-400 w-full" : "bg-leaf",
)}
style={overspent ? undefined : { width: `${availableShare(c) * 100}%` }}
/>
</div>
</td>
<td className="px-2 py-1.5 text-right align-top text-charcoal">{formatMoney(c.assigned)}</td>
<td className={cn("px-2 py-1.5 text-right align-top", c.activity < 0 ? "text-warm-gray" : "text-leaf-dark")}>
{formatMoney(c.activity, { signed: true })}
</td>
<td className={cn("px-3 py-1.5 text-right align-top font-semibold", overspent ? "text-red-600" : "text-leaf-dark")}>
{formatMoney(c.available)}
</td>
</tr>
);
})}
</tbody>
))}
</table>
</div>
<p className="text-[11px] text-warm-gray">Bar: available as a share of assigned. Red rows are overspent.</p>
</div>
);
};
@@ -0,0 +1,104 @@
import { formatDate, formatMoney, formatRange } from "./format";
import { EmptyNote, Subtle } from "./shared";
import type { RendererProps, YnabSpendingReportData } from "./types";
type Row = YnabSpendingReportData["rows"][number];
/** "2026-08" → "Aug" (or "Aug 26" when the rows span years); anything else as-is. */
const monthLabel = (key: string, withYear: boolean) =>
/^\d{4}-\d{2}/.test(key)
? formatDate(key, withYear ? { month: "short", year: "2-digit" } : { month: "short" })
: key;
const MonthBars = ({ rows }: { rows: Row[] }) => {
const ordered = [...rows].sort((a, b) => a.key.localeCompare(b.key));
const withYear = new Set(ordered.map((r) => r.key.slice(0, 4))).size > 1;
const label = (key: string) => monthLabel(key, withYear);
const max = Math.max(...ordered.map((r) => r.amount), 1);
return (
<div className="rounded-xl border border-sand-light bg-warm-white px-3 pt-4 pb-2">
<div className="flex h-44 items-end gap-1.5">
{ordered.map((r) => (
<div
key={r.key}
className="group flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-1"
title={`${monthLabel(r.key, true)}: ${formatMoney(r.amount)}`}
>
<span className="text-[10px] font-semibold tabular-nums text-warm-gray opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{formatMoney(r.amount).replace(/\.\d\d$/, "")}
</span>
<div
className="w-full max-w-10 rounded-t-md bg-amber-glow/80 group-hover:bg-amber-dark transition-colors"
style={{ height: `${Math.max(2, (Math.max(0, r.amount) / max) * 100)}%` }}
/>
</div>
))}
</div>
<div className="mt-1.5 flex gap-1.5 border-t border-sand-light pt-1.5">
{ordered.map((r) => (
<span key={r.key} className="min-w-0 flex-1 truncate text-center text-[10px] text-warm-gray">
{label(r.key)}
</span>
))}
</div>
</div>
);
};
const HorizontalBars = ({ rows }: { rows: Row[] }) => {
// Amounts are positive spending; a net refund (negative) sorts last with no bar.
const ordered = [...rows].sort((a, b) => b.amount - a.amount);
const max = Math.max(...ordered.map((r) => r.amount), 1);
return (
<ul className="flex flex-col gap-2.5">
{ordered.map((r) => (
<li key={r.key}>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="truncate text-charcoal">{r.key}</span>
<span className={`shrink-0 font-semibold tabular-nums ${r.amount < 0 ? "text-leaf-dark" : "text-charcoal"}`}>
{formatMoney(r.amount)}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<div className="h-2 flex-1 rounded-full bg-sand-light/70 overflow-hidden">
<div
className="h-full rounded-full bg-leaf"
style={{ width: `${(Math.max(0, r.amount) / max) * 100}%` }}
/>
</div>
{r.count != null && (
<span className="w-12 shrink-0 text-right text-[11px] text-warm-gray">
{r.count} txn{r.count === 1 ? "" : "s"}
</span>
)}
</div>
</li>
))}
</ul>
);
};
export const YnabSpendingReport = ({ data }: RendererProps<YnabSpendingReportData>) => {
const rows = data.rows ?? [];
return (
<div className="flex flex-col gap-4">
<div className="flex items-end justify-between gap-3">
<div>
<Subtle>{formatRange(data.start_date, data.end_date)}</Subtle>
<Subtle>Grouped by {data.group_by?.replace(/_/g, " ")}</Subtle>
</div>
<div className="text-right">
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">Total</div>
<div className="text-xl font-bold tabular-nums text-charcoal">{formatMoney(data.total)}</div>
</div>
</div>
{rows.length === 0 ? (
<EmptyNote>No spending in this period.</EmptyNote>
) : data.group_by === "month" ? (
<MonthBars rows={rows} />
) : (
<HorizontalBars rows={rows} />
)}
</div>
);
};
@@ -0,0 +1,48 @@
import { formatMoney, formatMonth } from "./format";
import { EmptyNote, SectionTitle, StatTile } from "./shared";
import type { RendererProps, YnabSummaryData } from "./types";
export const YnabSummary = ({ data }: RendererProps<YnabSummaryData>) => {
const overspent = data.overspent ?? [];
return (
<div className="flex flex-col gap-5">
<p className="text-sm text-warm-gray">{formatMonth(data.month)}</p>
<div className="grid grid-cols-2 gap-2">
<StatTile
label="Ready to assign"
value={formatMoney(data.ready_to_assign)}
tone={data.ready_to_assign < 0 ? "bad" : "good"}
/>
<StatTile label="Income" value={formatMoney(data.income)} />
<StatTile label="Assigned" value={formatMoney(data.assigned)} />
{/* Positive for spending, like the spending report. */}
<StatTile label="Spent" value={formatMoney(data.spent)} />
<StatTile
label="Net worth"
value={formatMoney(data.net_worth)}
tone={data.net_worth < 0 ? "bad" : "default"}
/>
{data.age_of_money != null && (
<StatTile label="Age of money" value={`${data.age_of_money} days`} />
)}
</div>
<section>
<SectionTitle>Overspent ({overspent.length})</SectionTitle>
{overspent.length === 0 ? (
<EmptyNote>Nothing overspent this month.</EmptyNote>
) : (
<ul className="divide-y divide-red-100 rounded-xl border border-red-200 bg-red-50/60">
{overspent.map((c) => (
<li key={c.name} className="flex items-center justify-between px-3 py-2 text-sm">
<span className="text-charcoal">{c.name}</span>
<span className="font-semibold tabular-nums text-red-600">
{formatMoney(c.available)}
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
};
@@ -0,0 +1,118 @@
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { cn } from "../../../lib/utils";
import { formatDate, formatMoney, formatRange } from "./format";
import { EmptyNote, Subtle } from "./shared";
import type { RendererProps, YnabTransactionsData } from "./types";
type SortKey = "date" | "payee" | "category" | "amount";
type Sort = { key: SortKey; dir: "asc" | "desc" };
const COLUMNS: { key: SortKey; label: string; align: "left" | "right" }[] = [
{ key: "date", label: "Date", align: "left" },
{ key: "payee", label: "Payee", align: "left" },
{ key: "category", label: "Category", align: "left" },
{ key: "amount", label: "Amount", align: "right" },
];
export const YnabTransactions = ({ data }: RendererProps<YnabTransactionsData>) => {
const [sort, setSort] = useState<Sort>({ key: "date", dir: "desc" });
const transactions = data.transactions ?? [];
const sorted = useMemo(() => {
const factor = sort.dir === "asc" ? 1 : -1;
return [...transactions].sort((a, b) => {
const cmp =
sort.key === "amount"
? a.amount - b.amount
: String(a[sort.key] ?? "").localeCompare(String(b[sort.key] ?? ""));
return cmp * factor;
});
}, [transactions, sort]);
const toggle = (key: SortKey) =>
setSort((s) =>
s.key === key
? { key, dir: s.dir === "asc" ? "desc" : "asc" }
: { key, dir: key === "date" || key === "amount" ? "desc" : "asc" },
);
if (transactions.length === 0) return <EmptyNote>No matching transactions.</EmptyNote>;
return (
<div className="flex flex-col gap-3">
<Subtle>
{formatRange(data.start_date, data.end_date)} · {data.count} transaction
{data.count === 1 ? "" : "s"}
{data.count > transactions.length && ` (showing ${transactions.length})`}
</Subtle>
<div className="overflow-x-auto rounded-xl border border-sand-light bg-warm-white">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-sand-light">
{COLUMNS.map((col) => {
const activeCol = sort.key === col.key;
const Icon = !activeCol ? ArrowUpDown : sort.dir === "asc" ? ArrowUp : ArrowDown;
return (
<th
key={col.key}
className={cn("px-2 py-2 first:pl-3 last:pr-3", col.align === "right" ? "text-right" : "text-left")}
aria-sort={activeCol ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
>
<button
onClick={() => toggle(col.key)}
className={cn(
"inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wide cursor-pointer hover:text-charcoal",
activeCol ? "text-charcoal" : "text-warm-gray",
)}
>
{col.label}
<Icon size={11} className={activeCol ? "" : "opacity-40"} />
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{sorted.map((t, i) => (
<tr key={i} className="border-t border-sand-light/60 align-top">
<td className="whitespace-nowrap py-1.5 pl-3 pr-2 text-warm-gray">
{formatDate(t.date, { month: "short", day: "numeric" })}
</td>
<td className="px-2 py-1.5">
<div className="text-charcoal">{t.payee}</div>
{t.memo && <div className="text-[11px] text-warm-gray">{t.memo}</div>}
</td>
<td className="px-2 py-1.5 text-warm-gray">{t.category}</td>
<td
className={cn(
"whitespace-nowrap py-1.5 pl-2 pr-3 text-right font-semibold tabular-nums",
t.amount < 0 ? "text-charcoal" : "text-leaf-dark",
)}
>
{formatMoney(t.amount, { signed: true })}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-sand bg-sand-light/40 font-bold">
<td className="py-2 pl-3 pr-2 text-charcoal" colSpan={3}>
Total
</td>
<td
className={cn(
"whitespace-nowrap py-2 pl-2 pr-3 text-right tabular-nums",
data.total < 0 ? "text-charcoal" : "text-leaf-dark",
)}
>
{formatMoney(data.total, { signed: true })}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
);
};
@@ -0,0 +1,51 @@
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
/**
* USD with an explicit sign: "−$12.50" for negatives and, when `signed`,
* "+$12.50" for positives. Amounts are dollars, not YNAB milliunits.
*/
export function formatMoney(amount: number, { signed = false } = {}): string {
const value = Number.isFinite(amount) ? amount : 0;
const abs = usd.format(Math.abs(value));
if (value < 0) return `−${abs}`;
if (signed && value > 0) return `+${abs}`;
return abs;
}
/** Tailwind text colour for a money value: red when negative. */
export function moneyTone(amount: number): string {
return amount < 0 ? "text-red-600" : "text-charcoal";
}
/** Parse a bare "YYYY-MM[-DD]" as a local date (avoids the UTC off-by-one). */
function parseDate(value: string): Date | null {
const m = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/.exec(value);
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3] ?? 1));
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatDate(
value: string | null | undefined,
options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" },
): string {
if (!value) return "";
const d = parseDate(value);
return d ? d.toLocaleDateString("en-US", options) : value;
}
/** "2026-10" or "2026-10-01" → "October 2026". */
export function formatMonth(value: string | null | undefined): string {
return formatDate(value, { month: "long", year: "numeric" });
}
export function formatRange(start?: string | null, end?: string | null): string {
if (start && end) return `${formatDate(start)} – ${formatDate(end)}`;
if (end) return `Through ${formatDate(end)}`;
return start ? `From ${formatDate(start)}` : "";
}
@@ -0,0 +1,84 @@
import React from "react";
import {
CalendarDays,
ChartColumn,
ChefHat,
NotebookText,
PiggyBank,
Receipt,
Search,
Shapes,
ShoppingCart,
Wallet,
type LucideIcon,
} from "lucide-react";
import type { Artifact } from "../../../api/artifactTypes";
import { MealieMealPlan } from "./MealieMealPlan";
import { MealieRecipe } from "./MealieRecipe";
import { MealieShoppingLists } from "./MealieShoppingLists";
import { ObsidianNote } from "./ObsidianNote";
import { SearchDocuments } from "./SearchDocuments";
import { UnknownArtifact } from "./UnknownArtifact";
import { YnabCategoryBudget } from "./YnabCategoryBudget";
import { YnabSpendingReport } from "./YnabSpendingReport";
import { YnabSummary } from "./YnabSummary";
import { YnabTransactions } from "./YnabTransactions";
import type { RendererProps } from "./types";
// biome-ignore lint/suspicious/noExplicitAny: each renderer narrows its own data shape
const RENDERERS: Record<string, React.ComponentType<RendererProps<any>>> = {
"ynab.summary": YnabSummary,
"ynab.category_budget": YnabCategoryBudget,
"ynab.spending_report": YnabSpendingReport,
"ynab.transactions": YnabTransactions,
"mealie.recipe": MealieRecipe,
"mealie.meal_plan": MealieMealPlan,
"mealie.shopping_lists": MealieShoppingLists,
"obsidian.note": ObsidianNote,
"search.documents": SearchDocuments,
};
const KIND_ICONS: Record<string, LucideIcon> = {
"ynab.summary": PiggyBank,
"ynab.category_budget": Wallet,
"ynab.spending_report": ChartColumn,
"ynab.transactions": Receipt,
"mealie.recipe": ChefHat,
"mealie.meal_plan": CalendarDays,
"mealie.shopping_lists": ShoppingCart,
"obsidian.note": NotebookText,
"search.documents": Search,
};
export const kindIcon = (kind: string): LucideIcon => KIND_ICONS[kind] ?? Shapes;
/** Falls back to the raw JSON if a renderer throws on an unexpected payload. */
class RenderBoundary extends React.Component<
{ kind: string; data: unknown; children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown) {
console.error(`Artifact renderer for ${this.props.kind} failed:`, error);
}
render() {
if (this.state.failed) return <UnknownArtifact kind={this.props.kind} data={this.props.data} />;
return this.props.children;
}
}
export const ArtifactRenderer = ({ artifact }: { artifact: Artifact }) => {
const Renderer = RENDERERS[artifact.kind];
if (!Renderer) return <UnknownArtifact kind={artifact.kind} data={artifact.data} />;
return (
<RenderBoundary key={artifact.id} kind={artifact.kind} data={artifact.data}>
<Renderer data={artifact.data} sourceRef={artifact.source_ref} />
</RenderBoundary>
);
};
@@ -0,0 +1,77 @@
import type React from "react";
import { ExternalLink } from "lucide-react";
import { cn } from "../../../lib/utils";
export const SectionTitle = ({ children }: { children: React.ReactNode }) => (
<h3 className="text-[11px] font-bold uppercase tracking-wider text-warm-gray mb-2">
{children}
</h3>
);
export const Subtle = ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p className={cn("text-xs text-warm-gray", className)}>{children}</p>
);
export const Chip = ({
icon,
children,
tone = "sand",
}: {
icon?: React.ReactNode;
children: React.ReactNode;
tone?: "sand" | "leaf" | "amber";
}) => (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium",
tone === "sand" && "bg-sand-light/70 text-charcoal",
tone === "leaf" && "bg-leaf-pale text-leaf-dark border border-leaf-light/50",
tone === "amber" && "bg-amber-pale text-amber-dark",
)}
>
{icon}
{children}
</span>
);
export const StatTile = ({
label,
value,
tone = "default",
}: {
label: string;
value: React.ReactNode;
tone?: "default" | "good" | "bad";
}) => (
<div className="rounded-xl border border-sand-light bg-warm-white px-3 py-2.5">
<div className="text-[11px] font-semibold uppercase tracking-wide text-warm-gray">{label}</div>
<div
className={cn(
"mt-0.5 text-lg font-bold tabular-nums",
tone === "default" && "text-charcoal",
tone === "good" && "text-leaf-dark",
tone === "bad" && "text-red-600",
)}
>
{value}
</div>
</div>
);
export const OpenExternal = ({ href, label }: { href: string; label: string }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-xl border border-sand bg-warm-white px-3 py-1.5 text-xs font-semibold text-leaf-dark hover:bg-leaf-pale hover:border-leaf-light transition-colors"
>
<ExternalLink size={13} />
{label}
</a>
);
export const EmptyNote = ({ children }: { children: React.ReactNode }) => (
<p className="rounded-xl border border-dashed border-sand px-3 py-4 text-center text-sm text-warm-gray">
{children}
</p>
);
@@ -0,0 +1,110 @@
// Per-kind `data` shapes (docs/cowork-ui-plan.md § V1 kinds). Renderers treat
// them as best-effort: optional chaining guards against partial payloads.
import type { ArtifactSourceRef } from "../../../api/artifactTypes";
export type SourceRef = ArtifactSourceRef;
export type MealieRecipeData = {
slug: string;
name: string;
description: string | null;
servings: string | number | null;
prep_time: string | null;
cook_time: string | null;
total_time: string | null;
ingredients: string[];
instructions: { title?: string; text: string }[];
tags: string[];
image_url?: string; // in the contract, not sent by the backend today
};
export type MealieMealPlanData = {
start_date: string;
end_date: string;
days: {
date: string;
meals: { entry_type: string; name: string; recipe_slug?: string; note?: string }[];
}[];
};
export type MealieShoppingListsData = {
lists: { name: string; items: { name: string; quantity: number | string | null; checked: boolean }[] }[];
};
export type ObsidianNoteData = {
path: string;
title: string;
markdown: string;
tags: string[];
frontmatter: Record<string, unknown>;
};
export type SearchDocumentsData = {
query: string;
source: string;
results: {
title: string;
snippet: string;
filename?: string;
path?: string;
document_type?: string;
date?: string;
}[];
};
export type YnabSummaryData = {
month: string;
ready_to_assign: number;
income: number;
assigned: number;
/** Positive for spending (-SUM of outflows). */
spent: number;
age_of_money?: number | null;
net_worth: number;
overspent: { name: string; available: number }[];
};
export type YnabTransactionsData = {
/** Null for open-ended searches ("all time through end_date"). */
start_date: string | null;
end_date: string;
count: number;
/** Net of outflows and inflows. */
total: number;
transactions: {
date: string;
payee: string;
category: string;
account: string;
memo?: string | null;
/** Signed: negative = outflow. */
amount: number;
}[];
};
export type YnabSpendingReportData = {
start_date: string;
end_date: string;
group_by: "category" | "category_group" | "payee" | "account" | "month" | string;
/** amount is positive for spending (-SUM); a net refund comes out negative. */
rows: { key: string; amount: number; count?: number }[];
total: number;
};
export type YnabCategoryBudgetData = {
month: string;
groups: {
name: string;
categories: {
name: string;
assigned: number;
/** Negative for spending. */
activity: number;
available: number;
goal_target?: number | null;
}[];
}[];
};
export type RendererProps<T> = { data: T; sourceRef: SourceRef | null };
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { artifactService } from "../../api/artifactService";
import type { Artifact, ArtifactRef } from "../../api/artifactTypes";
/** Windows at least this wide auto-open artifacts as tools produce them. */
export const AUTO_OPEN_MIN_WIDTH = 1280;
export type ArtifactEntry =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "ready"; artifact: Artifact };
export type ArtifactPanelView = "tab" | "library";
type OpenOptions = {
/** Set for automatic opens; they don't re-expand a panel the user collapsed. */
auto?: boolean;
};
/**
* State for the right-hand artifact panel: the open tabs, which one is
* active, and a per-id cache of fetched artifacts. Tabs reset whenever the
* conversation changes; the cache survives (artifact ids are unique).
*/
export function useArtifactPanel(conversationId: string | undefined) {
const [tabs, setTabs] = useState<ArtifactRef[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState<ArtifactPanelView>("tab");
const [cache, setCache] = useState<Record<string, ArtifactEntry>>({});
const cacheRef = useRef(cache);
cacheRef.current = cache;
// Set when the user collapses the panel, so auto-opens stop forcing it open.
const userCollapsedRef = useRef(false);
useEffect(() => {
setTabs([]);
setActiveId(null);
setIsOpen(false);
setView("tab");
userCollapsedRef.current = false;
}, [conversationId]);
const load = useCallback(async (id: string) => {
setCache((prev) => ({ ...prev, [id]: { status: "loading" } }));
try {
const artifact = await artifactService.getArtifact(id);
setCache((prev) => ({ ...prev, [id]: { status: "ready", artifact } }));
} catch (err) {
const error = err instanceof Error ? err.message : "Failed to load artifact";
setCache((prev) => ({ ...prev, [id]: { status: "error", error } }));
}
}, []);
const open = useCallback(
(ref: ArtifactRef, { auto = false }: OpenOptions = {}) => {
setTabs((prev) => (prev.some((t) => t.id === ref.id) ? prev : [...prev, ref]));
setActiveId(ref.id);
setView("tab");
if (!auto || !userCollapsedRef.current) setIsOpen(true);
const cached = cacheRef.current[ref.id];
if (!cached || cached.status === "error") load(ref.id);
},
[load],
);
const close = useCallback(
(id: string) => {
const index = tabs.findIndex((t) => t.id === id);
if (index === -1) return;
const next = tabs.filter((t) => t.id !== id);
setTabs(next);
// Activate the neighbour (right, else left) when the active tab closes.
if (activeId === id) setActiveId((next[index] ?? next[index - 1])?.id ?? null);
if (next.length === 0) setView("library");
},
[tabs, activeId],
);
const retry = useCallback((id: string) => load(id), [load]);
const expand = useCallback(() => {
userCollapsedRef.current = false;
setIsOpen(true);
}, []);
const collapse = useCallback(() => {
userCollapsedRef.current = true;
setIsOpen(false);
}, []);
/** Open the panel on the list of this conversation's artifacts. */
const showLibrary = useCallback(() => {
userCollapsedRef.current = false;
setView("library");
setIsOpen(true);
}, []);
const active = tabs.find((t) => t.id === activeId) ?? null;
return {
tabs,
active,
activeEntry: active ? (cache[active.id] ?? { status: "loading" as const }) : null,
isOpen,
view,
setView,
open,
close,
retry,
expand,
collapse,
showLibrary,
};
}
export type ArtifactPanelState = ReturnType<typeof useArtifactPanel>;
+163 -50
View File
@@ -1,34 +1,44 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useMatch } from "react-router-dom";
import { conversationService } from "../api/conversationService";
import {
conversationService,
UploadError,
type ToolInput,
} from "../api/conversationService";
import type { ArtifactRef } from "../api/artifactTypes";
import { validateAttachment, withAttachedFile } from "../lib/attachments";
import type { Conversation } from "./useConversations";
type Message = {
export type ToolStatus = "running" | "ok" | "error";
export type ToolMessage = {
speaker: "tool";
run_id: string;
tool: string;
label: string;
input?: ToolInput;
status: ToolStatus;
ms?: number;
artifact?: ArtifactRef | null;
// Client-side wall-clock bounds, used for the timeline's total duration.
startedAt?: number;
endedAt?: number;
// Synthesised run_id for a server that sends no run_id; such a row pairs
// with the next tool_end for the same tool.
legacy?: boolean;
};
export type TextMessage = {
speaker: "simba" | "user";
text: string;
speaker: "simba" | "user" | "tool";
image_key?: string | null;
};
const TOOL_MESSAGES: Record<string, string> = {
simba_search: "Searching Simba's records...",
web_search: "Searching the web...",
get_current_date: "Checking today's date...",
ynab_budget_summary: "Checking budget summary...",
ynab_account_balances: "Checking account balances...",
ynab_search_transactions: "Looking up transactions...",
ynab_spending_report: "Adding up spending...",
ynab_category_budget: "Checking category budgets...",
ynab_recent_changes: "Checking recent budget changes...",
ynab_sql: "Querying budget data...",
obsidian_search_notes: "Searching notes...",
obsidian_read_note: "Reading note...",
obsidian_create_note: "Saving note...",
obsidian_create_task: "Creating task...",
journal_get_today: "Reading today's journal...",
journal_get_tasks: "Getting tasks...",
journal_add_task: "Adding task...",
journal_complete_task: "Completing task...",
};
export type Message = TextMessage | ToolMessage;
const fallbackLabel = (tool: string) => `Using ${tool}…`;
let legacyRunCounter = 0;
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
@@ -37,6 +47,8 @@ type UseChatOptions = {
refreshConversations: () => Promise<void>;
onSessionExpired: () => void;
scrollToBottom: () => void;
/** Called when a tool in the streaming turn produced an artifact. */
onArtifact?: (ref: ArtifactRef, conversationId: string) => void;
};
export function useChat({
@@ -44,6 +56,7 @@ export function useChat({
refreshConversations,
onSessionExpired,
scrollToBottom,
onArtifact,
}: UseChatOptions) {
// ChatScreen renders under a single catch-all route so it isn't remounted
// when the first message navigates to /c/:id (which would abort the stream),
@@ -52,7 +65,9 @@ export function useChat({
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
// One image or text-like file to send with the next message.
const [pendingAttachment, setPendingAttachment] = useState<File | null>(null);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [stalled, setStalled] = useState(false);
const [streaming, setStreaming] = useState(false);
@@ -61,6 +76,8 @@ export function useChat({
const [thinkingDurationMs, setThinkingDurationMs] = useState<number | null>(null);
const stalledQueryRef = useRef<string | null>(null);
const onArtifactRef = useRef(onArtifact);
onArtifactRef.current = onArtifact;
const thinkingStartRef = useRef<number | null>(null);
const isMountedRef = useRef(true);
@@ -117,11 +134,22 @@ export function useChat({
const fetched = await conversationService.getConversation(conversationId);
if (cancelled) return;
setMessages(
(fetched.messages ?? []).map((m) => ({
text: m.text,
speaker: m.speaker,
image_key: m.image_key,
})),
(fetched.messages ?? []).map((m): Message => {
if (m.speaker === "tool") {
// Saved tool messages carry no input, status or duration.
// Pre-v2 servers send only the "[<tool> result]" header line.
const tool = m.tool ?? m.text.match(/^\[(\S+) result\]/)?.[1] ?? "";
return {
speaker: "tool",
run_id: m.id,
tool,
label: m.label ?? (tool ? fallbackLabel(tool) : "Used a tool"),
status: "ok",
artifact: m.artifact ?? null,
};
}
return { text: m.text, speaker: m.speaker, image_key: m.image_key };
}),
);
scrollToBottom();
} catch (err) {
@@ -140,7 +168,7 @@ export function useChat({
const sendMessage = useCallback(
async (query: string, simbaMode: boolean) => {
if ((!query.trim() && !pendingImage) || isLoading) return;
if ((!query.trim() && !pendingAttachment) || isLoading) return;
let activeId = conversationId;
if (!activeId) {
@@ -150,7 +178,7 @@ export function useChat({
activeId = created.id;
}
const imageFile = pendingImage;
const attachment = pendingAttachment;
setError(null);
setStalled(false);
@@ -161,7 +189,8 @@ export function useChat({
streamingActiveRef.current = false;
setStreaming(false);
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
setPendingImage(null);
setPendingAttachment(null);
setAttachmentError(null);
setIsLoading(true);
if (simbaMode) {
@@ -179,18 +208,27 @@ export function useChat({
try {
let imageKey: string | undefined;
if (imageFile) {
const uploadResult = await conversationService.uploadImage(
imageFile,
let fileKey: string | undefined;
if (attachment) {
const uploadResult = await conversationService.uploadAttachment(
attachment,
activeId,
);
imageKey = uploadResult.image_key;
let patch: Partial<TextMessage>;
if (uploadResult.kind === "image") {
imageKey = uploadResult.image_key;
patch = { image_key: imageKey };
} else {
fileKey = uploadResult.file_key;
patch = { text: withAttachedFile(query, uploadResult.name) };
}
updateMessages((prev) => {
const updated = [...prev];
for (let i = updated.length - 1; i >= 0; i--) {
if (updated[i].speaker === "user") {
updated[i] = { ...updated[i], image_key: imageKey };
const msg = updated[i];
if (msg.speaker === "user") {
updated[i] = { ...msg, ...patch };
break;
}
}
@@ -214,11 +252,56 @@ export function useChat({
// Any text after a tool call belongs in a fresh bubble.
streamingActiveRef.current = false;
setThinking(false);
const friendly =
TOOL_MESSAGES[event.tool] ?? `Using ${event.tool}...`;
updateMessages((prev) =>
prev.concat([{ text: friendly, speaker: "tool" }]),
);
const legacy = !event.run_id;
const row: ToolMessage = {
speaker: "tool",
run_id: event.run_id ?? `legacy-${++legacyRunCounter}`,
tool: event.tool,
label: event.label ?? fallbackLabel(event.tool),
input: event.input,
status: "running",
startedAt: Date.now(),
legacy,
};
updateMessages((prev) => prev.concat([row]));
} else if (event.type === "tool_end") {
if (event.artifact) onArtifactRef.current?.(event.artifact, activeId);
const now = Date.now();
updateMessages((prev) => {
// Pair by run_id; a legacy end (no run_id) pairs with the
// oldest still-running legacy row for the same tool.
const idx = prev.findIndex((m) =>
m.speaker === "tool" &&
(event.run_id
? m.run_id === event.run_id
: m.legacy && m.status === "running" && m.tool === event.tool),
);
const ended = {
status: event.status ?? "ok",
artifact: event.artifact ?? null,
endedAt: now,
} as const;
if (idx === -1) {
// End without a matching start: show it as a finished row.
return prev.concat([{
speaker: "tool",
run_id: event.run_id ?? `legacy-${++legacyRunCounter}`,
tool: event.tool,
label: event.label ?? fallbackLabel(event.tool),
ms: event.ms,
...ended,
}]);
}
const row = prev[idx] as ToolMessage;
const updated = [...prev];
updated[idx] = {
...row,
...ended,
label: event.label ?? row.label,
ms: event.ms ?? (row.startedAt != null ? now - row.startedAt : undefined),
};
return updated;
});
} else if (event.type === "content") {
setThinking(false);
if (thinkingStartRef.current !== null && thinkingDurationMs === null) {
@@ -228,6 +311,7 @@ export function useChat({
updateMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last.speaker === "tool") return prev;
updated[updated.length - 1] = {
...last,
text: last.text + event.delta,
@@ -247,10 +331,9 @@ export function useChat({
if (streamingActiveRef.current) {
updateMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
text: event.message,
};
const last = updated[updated.length - 1];
if (last.speaker === "tool") return prev;
updated[updated.length - 1] = { ...last, text: event.message };
return updated;
});
streamingActiveRef.current = false;
@@ -272,10 +355,13 @@ export function useChat({
},
abortController.signal,
imageKey,
fileKey,
);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// User cancelled — not an error to surface.
} else if (err instanceof UploadError) {
if (isMountedRef.current) setError(`Couldn't attach that file: ${err.message}`);
} else {
console.error("Failed to send query:", err);
if (
@@ -289,6 +375,17 @@ export function useChat({
}
} finally {
if (isMountedRef.current) {
// A stream that ends (or is cancelled) mid-call leaves rows that
// will never get a tool_end; stop their spinners.
setMessages((prev) =>
prev.some((m) => m.speaker === "tool" && m.status === "running")
? prev.map((m) =>
m.speaker === "tool" && m.status === "running"
? { ...m, status: "error", endedAt: Date.now() }
: m,
)
: prev,
);
setIsLoading(false);
setStreaming(false);
setThinking(false);
@@ -302,7 +399,7 @@ export function useChat({
}
},
[
pendingImage,
pendingAttachment,
isLoading,
conversationId,
createConversation,
@@ -319,6 +416,19 @@ export function useChat({
const clearError = useCallback(() => setError(null), []);
const attachFile = useCallback((file: File) => {
const problem = validateAttachment(file);
setAttachmentError(problem);
if (!problem) setPendingAttachment(file);
}, []);
const clearAttachment = useCallback(() => {
setPendingAttachment(null);
setAttachmentError(null);
}, []);
const clearAttachmentError = useCallback(() => setAttachmentError(null), []);
const retryStalled = useCallback(() => {
const q = stalledQueryRef.current;
if (!q) return;
@@ -334,8 +444,11 @@ export function useChat({
streaming,
thinking,
messagesLoading,
pendingImage,
setPendingImage,
pendingAttachment,
attachFile,
clearAttachment,
attachmentError,
clearAttachmentError,
sendMessage,
stopGeneration,
error,
+63
View File
@@ -0,0 +1,63 @@
// What can be attached to a message: images go to the vision model, text-like
// files are extracted server-side and appended to the turn.
export const ATTACHMENT_ACCEPT = "image/*,.pdf,.txt,.md,.markdown,.csv";
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
const IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
]);
const FILE_EXTENSIONS = new Set(["pdf", "txt", "md", "markdown", "csv"]);
const FILE_TYPES = new Set([
"application/pdf",
"text/plain",
"text/markdown",
"text/x-markdown",
"text/csv",
]);
export type AttachmentKind = "image" | "file";
export const fileExtension = (name: string) =>
name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
export function attachmentKind(file: File): AttachmentKind | null {
if (IMAGE_TYPES.has(file.type)) return "image";
if (FILE_TYPES.has(file.type) || FILE_EXTENSIONS.has(fileExtension(file.name))) {
return "file";
}
return null;
}
/** Returns an error message if the file can't be attached, else null. */
export function validateAttachment(file: File): string | null {
if (!attachmentKind(file)) {
return `Can't attach “${file.name}”. Use an image, PDF, TXT, MD or CSV file.`;
}
if (file.size > MAX_ATTACHMENT_BYTES) {
return `“${file.name}” is larger than 10 MB.`;
}
return null;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Saved user messages record an attached file as a trailing "📎 name" line.
const ATTACHED_FILE_RE = /(?:^|\n\n)📎 ([^\n]+)$/u;
export const withAttachedFile = (text: string, name: string) =>
text ? `${text}\n\n📎 ${name}` : `📎 ${name}`;
export function splitAttachedFile(text: string): { body: string; fileName: string | null } {
const match = text.match(ATTACHED_FILE_RE);
if (!match) return { body: text, fileName: null };
return { body: text.slice(0, match.index), fileName: match[1] };
}
@@ -0,0 +1,90 @@
{
"kind": "mealie.meal_plan",
"title": "Meal plan · Oct 12–18",
"data": {
"start_date": "2026-10-12",
"end_date": "2026-10-18",
"days": [
{
"date": "2026-10-12",
"meals": [
{
"entry_type": "breakfast",
"name": "Overnight oats",
"recipe_slug": "overnight-oats"
},
{
"entry_type": "dinner",
"name": "Chicken Tikka Masala",
"recipe_slug": "chicken-tikka-masala"
}
]
},
{
"date": "2026-10-13",
"meals": [
{
"entry_type": "dinner",
"name": "Leftovers",
"note": "Tikka masala + extra rice"
}
]
},
{
"date": "2026-10-14",
"meals": [
{
"entry_type": "lunch",
"name": "Lentil soup",
"recipe_slug": "lentil-soup"
},
{
"entry_type": "dinner",
"name": "Sheet-pan gnocchi",
"recipe_slug": "sheet-pan-gnocchi"
}
]
},
{
"date": "2026-10-16",
"meals": [
{
"entry_type": "dinner",
"name": "Taco night",
"note": "Pick up tortillas"
},
{
"entry_type": "side",
"name": "Elote salad",
"recipe_slug": "elote-salad"
}
]
},
{
"date": "2026-10-17",
"meals": [
{
"entry_type": "dinner",
"name": "Dinner out",
"note": "Burma Superstar 7pm"
}
]
},
{
"date": "2026-10-18",
"meals": [
{
"entry_type": "breakfast",
"name": "Dutch baby",
"recipe_slug": "dutch-baby"
},
{
"entry_type": "dinner",
"name": "Mushroom risotto",
"recipe_slug": "mushroom-risotto"
}
]
}
]
}
}
@@ -0,0 +1,56 @@
{
"kind": "mealie.recipe",
"title": "Chicken Tikka Masala",
"source_ref": {
"system": "mealie",
"id": "chicken-tikka-masala",
"url": "https://mealie.example.lan/g/home/r/chicken-tikka-masala"
},
"data": {
"slug": "chicken-tikka-masala",
"name": "Chicken Tikka Masala",
"description": "Weeknight tikka masala with yogurt-marinated chicken thighs and a creamy tomato sauce.",
"servings": "4 servings",
"prep_time": "20 minutes",
"cook_time": "35 minutes",
"total_time": "55 minutes",
"ingredients": [
"1.5 lb boneless chicken thighs, cut into chunks",
"1 cup plain whole-milk yogurt",
"2 tbsp garam masala, divided",
"1 tbsp lemon juice",
"2 tbsp ghee or neutral oil",
"1 large onion, finely diced",
"4 cloves garlic, minced",
"1 tbsp grated ginger",
"1 can (28 oz) crushed tomatoes",
"3/4 cup heavy cream",
"1 tsp kosher salt",
"Cilantro and basmati rice, to serve"
],
"instructions": [
{
"title": "Marinate",
"text": "Mix the yogurt, 1 tbsp garam masala, lemon juice and salt. Toss with the chicken and rest at least 15 minutes."
},
{
"text": "Broil the chicken on a sheet pan for 8–10 minutes until charred at the edges."
},
{
"title": "Sauce",
"text": "Melt the ghee in a large pan. Cook the onion until golden, about 8 minutes, then add the garlic, ginger and the rest of the garam masala for 1 minute."
},
{
"text": "Add the tomatoes and simmer 15 minutes. Stir in the cream and the chicken and simmer 5 more minutes."
},
{
"text": "Finish with cilantro and serve over rice."
}
],
"tags": [
"Indian",
"Weeknight",
"Freezer friendly"
]
}
}
@@ -0,0 +1,67 @@
{
"kind": "mealie.shopping_lists",
"title": "Shopping lists",
"data": {
"lists": [
{
"name": "Groceries",
"items": [
{
"name": "Chicken thighs",
"quantity": 1.5,
"checked": false
},
{
"name": "Crushed tomatoes (28 oz)",
"quantity": 1.0,
"checked": false
},
{
"name": "Heavy cream",
"quantity": 1.0,
"checked": false
},
{
"name": "Tortillas",
"quantity": 2.0,
"checked": false
},
{
"name": "Whole-milk yogurt",
"quantity": 1.0,
"checked": true
},
{
"name": "Cilantro",
"quantity": 1.0,
"checked": true
}
]
},
{
"name": "Costco",
"items": [
{
"name": "Cat litter",
"quantity": 2.0,
"checked": false
},
{
"name": "Olive oil",
"quantity": 1.0,
"checked": false
},
{
"name": "Paper towels",
"quantity": 1.0,
"checked": true
}
]
},
{
"name": "Pet store",
"items": []
}
]
}
}
@@ -0,0 +1,24 @@
{
"kind": "obsidian.note",
"title": "2026-09 annual checkup",
"source_ref": {
"system": "obsidian",
"path": "Simba/Vet/2026-09 annual checkup.md",
"url": "obsidian://open?vault=Home&file=Simba%2FVet%2F2026-09%20annual%20checkup.md"
},
"data": {
"path": "Simba/Vet/2026-09 annual checkup.md",
"title": "2026-09 annual checkup",
"tags": [
"simba",
"vet",
"health"
],
"frontmatter": {
"date": "2026-09-18",
"vet": "Mission Pet Hospital",
"weight_lb": 11.4
},
"markdown": "# Annual checkup\n\n**Vet:** Dr. Patel, Mission Pet Hospital \n**Weight:** 11.4 lb (down from 11.9)\n\n## Notes\n\n- Teeth look good; mild tartar on the upper molars.\n- Weight loss is fine, but keep an eye on it. Re-weigh in 3 months.\n- Vaccines: FVRCP booster given. Rabies is due **2027-03**.\n\n## Follow-ups\n\n- [x] Book dental cleaning quote\n- [ ] Switch to the weight-management kibble\n- [ ] Re-weigh in December\n\n| Item | Cost |\n|---|---|\n| Exam | $72.00 |\n| FVRCP | $38.00 |\n| **Total** | **$110.00** |\n\n> Simba was a perfect gentleman until the thermometer.\n"
}
}
@@ -0,0 +1,31 @@
{
"kind": "search.documents",
"title": "Search: Simba vaccination records",
"data": {
"query": "Simba vaccination records",
"source": "paperless",
"results": [
{
"title": "2026-09-18_mission-pet-hospital",
"snippet": "Patient: Simba (feline, DSH, M/N). Services: annual wellness exam, FVRCP vaccine (3-year), fecal test. Next due: rabies 03/2027. Total $110.00.",
"filename": "2026-09-18_mission-pet-hospital.pdf",
"document_type": "Invoice",
"date": "2026-09-18"
},
{
"title": "rabies-certificate-2024",
"snippet": "This certifies that Simba has been vaccinated against rabies. Vaccine: Purevax Feline Rabies, lot 3K91A. Date given 03/14/2024. Expires 03/14/2027.",
"filename": "rabies-certificate-2024.pdf",
"document_type": "Certificate",
"date": "2024-03-14"
},
{
"title": "2022-05-09_sf-spca-adoption",
"snippet": "Simba, approx. 10 weeks. Vaccines given: FVRCP #1 (2022-05-02), deworming. Microchip 985112004567213 registered to adopter.",
"filename": "2022-05-09_sf-spca-adoption.pdf",
"document_type": "Pet records",
"date": "2022-05-09"
}
]
}
}
@@ -0,0 +1,105 @@
{
"kind": "ynab.category_budget",
"title": "Category budget · October 2026",
"data": {
"month": "2026-10",
"groups": [
{
"name": "Bills",
"categories": [
{
"name": "Rent",
"assigned": 2400.0,
"activity": -2400.0,
"available": 0.0,
"goal_target": 2400.0
},
{
"name": "Electric",
"assigned": 120.0,
"activity": -84.31,
"available": 35.69
},
{
"name": "Internet",
"assigned": 70.0,
"activity": -70.0,
"available": 0.0,
"goal_target": 70.0
},
{
"name": "Phone",
"assigned": 85.0,
"activity": 0.0,
"available": 85.0
}
]
},
{
"name": "Everyday",
"categories": [
{
"name": "Groceries",
"assigned": 650.0,
"activity": -412.88,
"available": 237.12,
"goal_target": 650.0
},
{
"name": "Dining Out",
"assigned": 200.0,
"activity": -286.42,
"available": -86.42
},
{
"name": "Transportation",
"assigned": 180.0,
"activity": -61.2,
"available": 118.8
}
]
},
{
"name": "Simba",
"categories": [
{
"name": "Pet Food",
"assigned": 90.0,
"activity": -64.99,
"available": 25.01
},
{
"name": "Pet Care",
"assigned": 60.0,
"activity": -83.1,
"available": -23.1
},
{
"name": "Litter",
"assigned": 40.0,
"activity": -18.49,
"available": 21.51
}
]
},
{
"name": "Savings",
"categories": [
{
"name": "Emergency Fund",
"assigned": 500.0,
"activity": 0.0,
"available": 8250.0,
"goal_target": 10000.0
},
{
"name": "Vacation",
"assigned": 250.0,
"activity": 0.0,
"available": 1340.0
}
]
}
]
}
}
@@ -0,0 +1,57 @@
{
"kind": "ynab.spending_report",
"title": "Spending by category · Sep 1–30",
"data": {
"start_date": "2026-09-01",
"end_date": "2026-09-30",
"group_by": "category",
"total": 3642.21,
"rows": [
{
"key": "Rent",
"amount": 2400.0,
"count": 1
},
{
"key": "Groceries",
"amount": 588.14,
"count": 14
},
{
"key": "Dining Out",
"amount": 243.7,
"count": 9
},
{
"key": "Pet Care",
"amount": 148.0,
"count": 2
},
{
"key": "Electric",
"amount": 97.55,
"count": 1
},
{
"key": "Transportation",
"amount": 74.32,
"count": 5
},
{
"key": "Internet",
"amount": 70.0,
"count": 1
},
{
"key": "Pet Food",
"amount": 62.5,
"count": 2
},
{
"key": "Clothing",
"amount": -42.0,
"count": 2
}
]
}
}
@@ -0,0 +1,27 @@
{
"kind": "ynab.summary",
"title": "Budget · October 2026",
"data": {
"month": "2026-10",
"ready_to_assign": 412.37,
"income": 7850.0,
"assigned": 7437.63,
"spent": 3921.48,
"age_of_money": 41,
"net_worth": 84213.92,
"overspent": [
{
"name": "Dining Out",
"available": -86.42
},
{
"name": "Pet Care",
"available": -23.1
},
{
"name": "Gifts",
"available": -5.99
}
]
}
}
@@ -0,0 +1,78 @@
{
"kind": "ynab.transactions",
"title": "Transactions · Sep 12–Oct 12",
"data": {
"start_date": "2026-09-12",
"end_date": "2026-10-12",
"count": 9,
"total": -286.42,
"transactions": [
{
"date": "2026-10-10",
"payee": "Tartine Bakery",
"category": "Dining Out",
"account": "Chase Sapphire",
"amount": -18.75
},
{
"date": "2026-10-08",
"payee": "Burma Superstar",
"category": "Dining Out",
"account": "Chase Sapphire",
"memo": "Dinner with Alex",
"amount": -72.4
},
{
"date": "2026-10-05",
"payee": "Philz Coffee",
"category": "Dining Out",
"account": "Checking",
"amount": -6.25
},
{
"date": "2026-10-03",
"payee": "Souvla",
"category": "Dining Out",
"account": "Chase Sapphire",
"amount": -34.1
},
{
"date": "2026-09-30",
"payee": "Venmo",
"category": "Dining Out",
"account": "Checking",
"memo": "Split pizza refund",
"amount": 15.0
},
{
"date": "2026-09-27",
"payee": "Zuni Cafe",
"category": "Dining Out",
"account": "Chase Sapphire",
"memo": "Anniversary",
"amount": -121.32
},
{
"date": "2026-09-22",
"payee": "Philz Coffee",
"category": "Dining Out",
"account": "Checking",
"amount": -6.25
},
{
"date": "2026-09-18",
"payee": "Golden Boy Pizza",
"category": "Dining Out",
"account": "Chase Sapphire",
"amount": -29.6
},
{
"date": "2026-09-14",
"payee": "Tartine Bakery",
"category": "Dining Out",
"account": "Chase Sapphire",
"amount": -12.75
}
]
}
}
@@ -0,0 +1,679 @@
#!/usr/bin/env node
// Dependency-free mock of the SimbaRAG API for frontend work on the Cowork UI.
// Speaks the contract in docs/cowork-ui-plan.md: serves dist/ with an SPA
// catch-all, stubs auth + conversation CRUD + artifacts, and streams
// contract-shaped SSE from /api/conversation/stream-query.
//
// yarn build && node test-fixtures/cowork/mock-server.mjs [port]
//
// Seed localStorage access_token/refresh_token with any value to get past
// the auth gate. The stream scenario is picked by a keyword in the query:
// "parallel" two interleaved calls, one with an artifact
// "error" a call that fails, then an answer
// "legacy" old-server events: {type:"tool_start",tool} / {type:"tool_end",tool}
// "plain" no tools, just a streamed answer
// artifact panel scenarios, one tool call each with that kind's fixture:
// "recipe" | "meal" | "shopping" | "note" | "vaccin" (search.documents) |
// "budget" (summary + category budget, newest wins) | "by category"
// (spending report) | "trend" (month-grouped spending) | "transactions" |
// "philz" (open-ended transactions, start_date null) | "everything"
// (all v1 kinds) | "broken" (GET /api/artifacts/<id> 500s) | "mystery"
// (unknown kind → JSON fallback)
// anything else: two sequential calls, then a streamed answer
// any query sent with a file_key: reads the attached file (by name)
// Conversations "seeded" (timeline), "everything" (one saved tool message
// per artifact kind) and "attachment" (a saved 📎 file message) exist up front.
//
// Uploads (POST /api/conversation/upload) follow the contract: JPEG/PNG/WebP/
// HEIC -> {kind:"image"}, PDF/TXT/MD/CSV (by MIME or extension) ->
// {kind:"file"}, anything else 415, files named *scan*.pdf 422 (no text
// layer). Uploaded images are served back via /api/conversation/image/<key>.
// GET /__mock/requests returns recorded API request bodies (multipart files
// summarised as {filename, type, size}), newest last.
// artifacts/<kind>.json holds one ToolArtifact per v1 kind, matching what the
// backend tools emit. Any other *.json in this directory with `kind` and
// `data` is loaded as an artifact of "seeded" (`id` defaults to the file name).
// MOCK_DELAY_SCALE=0.2 speeds the stream up; 0 makes it instant.
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(here, "../../dist");
const port = Number(process.argv[2] ?? process.env.PORT ?? 5173);
const delayScale = Number(process.env.MOCK_DELAY_SCALE ?? 1);
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff2": "font/woff2",
".webmanifest": "application/manifest+json",
};
const now = () => new Date().toISOString();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms * delayScale));
const uuid = () => crypto.randomUUID();
// ---------------------------------------------------------------- state
/** @type {Map<string, any>} full Artifact records by id */
const artifacts = new Map();
/** @type {Map<string, {id, name, created_at, updated_at, messages: any[]}>} */
const conversations = new Map();
/** Uploaded objects by S3-style key: {bytes, type, name} */
const blobs = new Map();
/** Recorded API requests: {method, path, body} */
const requests = [];
function addArtifact(conversationId, tool, a) {
const record = {
id: a.id ?? uuid(),
kind: a.kind,
title: a.title,
data: a.data ?? {},
source_ref: a.source_ref ?? null,
tool: a.tool ?? tool,
conversation_id: conversationId,
message_id: a.message_id ?? null,
created_at: a.created_at ?? now(),
};
if (a.broken) record.broken = true; // GET /api/artifacts/<id> answers 500
artifacts.set(record.id, record);
return record;
}
const refOf = (a) => (a ? { id: a.id, kind: a.kind, title: a.title } : null);
// Spending amounts are positive (-SUM of outflows), as the backend sends them.
const SPENDING = {
kind: "ynab.spending_report",
title: "Spending by category · Oct 1–12",
data: {
start_date: "2026-10-01",
end_date: "2026-10-12",
group_by: "category",
rows: [
{ key: "Groceries", amount: 412.18, count: 9 },
{ key: "Dining Out", amount: 186.4, count: 6 },
{ key: "Cat Supplies", amount: 74.99, count: 2 },
],
total: 673.57,
},
};
const RECIPE = {
kind: "mealie.recipe",
title: "Miso Salmon",
data: {
slug: "miso-salmon",
name: "Miso Salmon",
description: "Weeknight salmon glazed with white miso.",
servings: 2,
prep_time: "10 min",
cook_time: "12 min",
total_time: "22 min",
ingredients: ["2 salmon fillets", "2 tbsp white miso", "1 tbsp mirin"],
instructions: [{ text: "Whisk the glaze." }, { text: "Broil 10–12 minutes." }],
tags: ["dinner", "fish"],
},
source_ref: { system: "mealie", id: "miso-salmon" },
};
function newConversation(id = uuid(), name = "New Conversation") {
const c = { id, name, created_at: now(), updated_at: now(), messages: [] };
conversations.set(id, c);
return c;
}
function pushMessage(c, msg) {
const m = { id: uuid(), created_at: now(), image_key: null, ...msg };
c.messages.push(m);
c.updated_at = now();
return m;
}
// Saved tool messages as GET /api/conversation/<id> returns them: the header
// line in `text`, plus tool/label/artifact.
function seed() {
const c = newConversation("seeded", "Budget check (seeded)");
pushMessage(c, { speaker: "user", text: "How much did we spend on food this month?" });
const spend = addArtifact(c.id, "ynab_spending_report", SPENDING);
pushMessage(c, {
speaker: "tool",
text: "[get_current_date result]",
tool: "get_current_date",
label: "Checking today's date…",
artifact: null,
});
pushMessage(c, {
speaker: "tool",
text: "[ynab_spending_report result]",
tool: "ynab_spending_report",
label: "Building spending report…",
artifact: refOf(spend),
});
pushMessage(c, {
speaker: "simba",
text: "You've spent **$598.58** on food so far in October: $412.18 on groceries and $186.40 dining out.",
});
// A pre-v2 saved tool message: no tool/label/artifact fields at all.
pushMessage(c, { speaker: "user", text: "Anything on the web about miso salmon?" });
pushMessage(c, { speaker: "tool", text: "[web_search result]" });
pushMessage(c, { speaker: "simba", text: "Plenty — broil it for 10–12 minutes." });
}
function loadFixtureArtifacts() {
for (const f of fs.readdirSync(here)) {
if (!f.endsWith(".json")) continue;
try {
const a = JSON.parse(fs.readFileSync(path.join(here, f), "utf8"));
if (a && a.kind && a.data) {
addArtifact(a.conversation_id ?? "seeded", a.tool ?? "fixture", {
id: path.basename(f, ".json"),
...a,
});
}
} catch (err) {
console.warn(`skipping fixture ${f}: ${err.message}`);
}
}
}
// Per-kind fixtures (artifacts/<kind>.json) plus a few variants.
const FIXTURES = {};
for (const f of fs.readdirSync(path.join(here, "artifacts"))) {
if (!f.endsWith(".json")) continue;
const a = JSON.parse(fs.readFileSync(path.join(here, "artifacts", f), "utf8"));
FIXTURES[a.kind] = a;
}
const EXTRA_FIXTURES = {
spendingByMonth: {
kind: "ynab.spending_report",
title: "Spending by month · Jan 1–Oct 12",
data: {
start_date: "2026-01-01",
end_date: "2026-10-12",
group_by: "month",
total: 2381.94,
rows: [
{ key: "2026-01", amount: 188.4, count: 7 },
{ key: "2026-02", amount: 302.55, count: 11 },
{ key: "2026-03", amount: 241.1, count: 8 },
{ key: "2026-04", amount: 150.0, count: 5 },
{ key: "2026-05", amount: 276.9, count: 9 },
{ key: "2026-06", amount: 333.12, count: 12 },
{ key: "2026-07", amount: 198.45, count: 6 },
{ key: "2026-08", amount: 221.3, count: 8 },
{ key: "2026-09", amount: 183.7, count: 7 },
{ key: "2026-10", amount: 286.42, count: 9 },
],
},
},
// Open-ended search (payee filter, no start date): start_date is null.
openEndedTransactions: {
kind: "ynab.transactions",
title: "Transactions · through 2026-10-12",
data: {
start_date: null,
end_date: "2026-10-12",
count: 3,
total: -18.75,
transactions: [
{ date: "2026-10-05", payee: "Philz Coffee", category: "Dining Out", account: "Checking", amount: -6.25 },
{ date: "2026-09-22", payee: "Philz Coffee", category: "Dining Out", account: "Checking", amount: -6.25 },
{ date: "2026-08-30", payee: "Philz Coffee", category: "Dining Out", account: "Checking", memo: "Beans", amount: -6.25 },
],
},
},
mystery: {
kind: "homebox.item",
title: "Cat tree (Homebox)",
data: { name: "Cat tree", location: "Living room", quantity: 1, labels: ["simba"] },
},
};
const TOOL_FOR_KIND = {
"mealie.recipe": "mealie_get_recipe",
"mealie.meal_plan": "mealie_meal_plans",
"mealie.shopping_lists": "mealie_shopping_lists",
"obsidian.note": "obsidian_read_note",
"search.documents": "simba_search",
"ynab.summary": "ynab_budget_summary",
"ynab.transactions": "ynab_search_transactions",
"ynab.spending_report": "ynab_spending_report",
"ynab.category_budget": "ynab_category_budget",
};
const toolFor = (a) => TOOL_FOR_KIND[a.kind] ?? "homebox_get_item";
// A conversation holding one saved tool message per artifact kind, for the
// panel's library and reload checks.
function seedEverything() {
const c = newConversation("everything", "Everything Simba knows");
pushMessage(c, { speaker: "user", text: "Show me everything" });
let t = Date.parse("2026-10-12T15:00:00Z");
for (const fx of [...Object.values(FIXTURES), ...Object.values(EXTRA_FIXTURES)]) {
const tool = toolFor(fx);
const msg = pushMessage(c, { speaker: "tool", text: `[${tool} result]`, tool, label: label(tool), artifact: null });
const a = addArtifact(c.id, tool, { ...fx, message_id: msg.id, created_at: new Date((t += 60_000)).toISOString() });
msg.artifact = refOf(a);
}
pushMessage(c, { speaker: "simba", text: "Here's everything I could find — open any artifact from the panel." });
}
// ---------------------------------------------------------------- SSE scenarios
const LABELS = {
get_current_date: "Checking today's date…",
ynab_spending_report: "Building spending report…",
mealie_get_recipe: "Opening recipe…",
obsidian_search_notes: "Searching notes…",
web_search: "Searching the web…",
simba_search: "Searching Simba's records…",
mealie_meal_plans: "Checking the meal plan…",
mealie_shopping_lists: "Checking shopping lists…",
obsidian_read_note: "Reading note…",
ynab_budget_summary: "Checking budget…",
ynab_search_transactions: "Looking up transactions…",
ynab_category_budget: "Checking category budgets…",
};
const label = (tool) => LABELS[tool] ?? `Using ${tool}…`;
function seedAttachment() {
const c = newConversation("attachment", "Vet bill questions");
pushMessage(c, { speaker: "user", text: "How much was the dental cleaning?\n\n📎 vet-invoice-2026-09.pdf" });
pushMessage(c, { speaker: "simba", text: "The dental cleaning on the September invoice was **$412.50**." });
}
seed();
seedEverything();
seedAttachment();
loadFixtureArtifacts();
/** Artifact-panel scenarios: one tool call per artifact, by keyword. */
function artifactScenario(q) {
if (q.includes("everything")) return Object.keys(TOOL_FOR_KIND).map((k) => FIXTURES[k]);
if (q.includes("broken")) return [{ ...FIXTURES["ynab.summary"], title: "Broken artifact", broken: true }];
if (q.includes("mystery")) return [EXTRA_FIXTURES.mystery];
if (q.includes("trend")) return [EXTRA_FIXTURES.spendingByMonth];
if (q.includes("philz")) return [EXTRA_FIXTURES.openEndedTransactions];
if (q.includes("budget")) return [FIXTURES["ynab.summary"], FIXTURES["ynab.category_budget"]];
if (q.includes("recipe")) return [FIXTURES["mealie.recipe"]];
if (q.includes("meal")) return [FIXTURES["mealie.meal_plan"]];
if (q.includes("shopping")) return [FIXTURES["mealie.shopping_lists"]];
if (q.includes("note")) return [FIXTURES["obsidian.note"]];
if (q.includes("vaccin")) return [FIXTURES["search.documents"]];
if (q.includes("by category")) return [FIXTURES["ynab.spending_report"]];
if (q.includes("transactions")) return [FIXTURES["ynab.transactions"]];
return null;
}
/**
* Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded
* into contract-shaped events; ctx persists tool messages and artifacts.
*/
function scenario(query, file) {
const q = query.toLowerCase();
const answer = (text) =>
text.match(/\S+\s*/g).map((w) => [60, { type: "content", delta: w }]);
if (file) {
return [
[200, { type: "thinking_delta", delta: `Reading the attached ${file.name}…` }],
[300, { start: "f1", tool: "simba_search", input: { query: (query || file.name).slice(0, 200) } }],
[600, { end: "f1" }],
...answer(`I read **${file.name}**. It looks like a vet invoice; the dental cleaning came to $412.50.`),
];
}
if (q.includes("plain")) {
return [[300, { type: "thinking" }], ...answer("Meow. Nothing to look up for that one.")];
}
if (q.includes("legacy")) {
return [
[300, { type: "tool_start", tool: "web_search" }],
[1200, { type: "tool_end", tool: "web_search" }],
[200, { type: "tool_start", tool: "simba_search" }],
[900, { type: "tool_end", tool: "simba_search" }],
...answer("An older server sent those tool events without run ids."),
];
}
if (q.includes("parallel")) {
return [
[300, { start: "r1", tool: "ynab_spending_report", input: { start_date: "2026-10-01", end_date: "2026-10-12", group_by: "category" } }],
[80, { start: "r2", tool: "mealie_get_recipe", input: { slug: "miso-salmon" } }],
[900, { end: "r2", artifact: RECIPE }],
[1100, { end: "r1", artifact: SPENDING }],
...answer("Food spending is **$598.58** this month, and I pulled up the miso salmon recipe for tonight."),
];
}
if (q.includes("error")) {
return [
[300, { start: "e1", tool: "obsidian_search_notes", input: { query: "vet appointment", limit: 5 } }],
[800, { end: "e1", status: "error" }],
[200, { start: "e2", tool: "web_search", input: { query: "cat vet near me open sunday" } }],
[1000, { end: "e2" }],
...answer("I couldn't search your notes, but a couple of vets nearby are open on Sundays."),
];
}
const artifactsForQuery = artifactScenario(q);
if (artifactsForQuery) {
return [
[250, { type: "thinking" }],
...artifactsForQuery.flatMap((fx, i) => [
[200, { start: `a${i}`, tool: toolFor(fx), input: { query: query.slice(0, 200) } }],
[500, { end: `a${i}`, artifact: fx }],
]),
...answer("Here's what I found. Let me know if you want more detail."),
];
}
return [
[300, { type: "thinking" }],
[200, { type: "thinking_delta", delta: "Need today's date before the budget lookup." }],
[300, { start: "s1", tool: "get_current_date", input: {} }],
[400, { end: "s1" }],
[200, { start: "s2", tool: "ynab_spending_report", input: { month: "2026-10", group_by: "category", include_hidden_categories_with_a_long_name: true } }],
[1500, { end: "s2" }],
...answer("You've spent **$598.58** on food so far in October: $412.18 on groceries and $186.40 dining out."),
];
}
async function streamQuery(req, res, body) {
const c = conversations.get(body.conversation_id) ?? newConversation(body.conversation_id);
let saved = body.query ?? "";
let file = null;
if (body.file_key) {
file = blobs.get(body.file_key);
if (!body.file_key.startsWith(`conversations/${c.id}/files/`) || !file) {
return json(res, 400, { error: "invalid file_key" });
}
saved = `${saved}\n\n📎 ${file.name}`.trim();
}
pushMessage(c, { speaker: "user", text: saved, image_key: body.image_key ?? null });
if (c.name === "New Conversation") c.name = (body.query || file?.name || "Attachment").slice(0, 40);
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let closed = false;
res.on("close", () => (closed = true));
const send = (event) => !closed && res.write(`data: ${JSON.stringify(event)}\n\n`);
const runs = new Map(); // key -> {run_id, tool, started}
let text = "";
for (const [delay, step] of scenario(body.query ?? "", file)) {
await sleep(delay);
if (closed) return;
if (step.start) {
const run = { run_id: uuid(), tool: step.tool, started: Date.now() };
runs.set(step.start, run);
send({ type: "tool_start", run_id: run.run_id, tool: run.tool, label: label(run.tool), input: step.input ?? {} });
} else if (step.end) {
const run = runs.get(step.end);
const status = step.status ?? "ok";
const msg = pushMessage(c, {
speaker: "tool",
text: `[${run.tool} result]`,
tool: run.tool,
label: label(run.tool),
artifact: null,
});
let ref = null;
if (step.artifact && status === "ok") {
ref = refOf(addArtifact(c.id, run.tool, { ...step.artifact, message_id: msg.id }));
msg.artifact = ref;
}
send({
type: "tool_end",
run_id: run.run_id,
tool: run.tool,
label: label(run.tool),
status,
ms: Date.now() - run.started,
artifact: ref,
});
} else {
if (step.type === "tool_end") {
pushMessage(c, { speaker: "tool", text: `[${step.tool} result]` });
}
if (step.type === "content") text += step.delta;
send(step);
}
}
pushMessage(c, { speaker: "simba", text });
send({ type: "response", message: text });
res.end("data: [DONE]\n\n");
}
// ---------------------------------------------------------------- HTTP
const json = (res, status, body) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
async function readRaw(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks);
}
/** Minimal multipart/form-data parser: {fields, files: {name: {filename, type, data}}}. */
function parseMultipart(buf, contentType) {
const boundary = /boundary=(?:"([^"]+)"|([^;]+))/.exec(contentType ?? "");
const out = { fields: {}, files: {} };
if (!boundary) return out;
const delim = Buffer.from(`--${boundary[1] ?? boundary[2]}`);
let pos = buf.indexOf(delim);
while (pos !== -1) {
const start = pos + delim.length + 2; // skip CRLF
const next = buf.indexOf(delim, start);
if (next === -1) break;
const part = buf.subarray(start, next - 2); // strip trailing CRLF
const headerEnd = part.indexOf("\r\n\r\n");
const headers = part.subarray(0, headerEnd).toString("utf8");
const data = part.subarray(headerEnd + 4);
const name = /name="([^"]*)"/.exec(headers)?.[1];
const filename = /filename="([^"]*)"/.exec(headers)?.[1];
if (name && filename !== undefined) {
const type = /Content-Type:\s*([^\r\n]+)/i.exec(headers)?.[1]?.trim() ?? "";
out.files[name] = { filename, type, data };
} else if (name) {
out.fields[name] = data.toString("utf8");
}
pos = next;
}
return out;
}
/** Read the body once: JSON, or multipart form as {fields, files}. */
async function readBody(req) {
const raw = await readRaw(req);
const type = req.headers["content-type"] ?? "";
if (type.startsWith("multipart/form-data")) return { form: parseMultipart(raw, type) };
try {
return raw.length ? JSON.parse(raw.toString("utf8")) : {};
} catch {
return {};
}
}
const IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]);
const FILE_TYPES = { "application/pdf": "pdf", "text/plain": "txt", "text/markdown": "md", "text/csv": "csv" };
const FILE_EXTS = new Set(["pdf", "txt", "md", "csv"]);
function upload(res, form) {
const file = form?.files.file;
const conversationId = form?.fields.conversation_id;
if (!file || !conversationId) return json(res, 400, { error: "file and conversation_id are required" });
if (!conversations.has(conversationId)) return json(res, 404, { error: "conversation not found" });
if (file.data.length > 10 * 1024 * 1024) return json(res, 413, { error: "File too large" });
const type = file.type.split(";")[0].trim().toLowerCase();
if (IMAGE_TYPES.has(type)) {
const key = `conversations/${conversationId}/${uuid()}.${type.split("/")[1].replace("jpeg", "jpg")}`;
blobs.set(key, { bytes: file.data, type, name: file.filename });
return json(res, 200, { kind: "image", image_key: key });
}
const fromName = path.extname(file.filename).slice(1).toLowerCase();
const ext = FILE_TYPES[type] ?? (FILE_EXTS.has(fromName) ? fromName : null);
if (!ext) {
return json(res, 415, { error: `Unsupported file type: ${type || file.filename}. Allowed: images, PDF, TXT, MD, CSV` });
}
if (ext === "pdf" && /scan/i.test(file.filename)) {
return json(res, 422, { error: "This PDF has no text layer (scanned?), so Simba can't read it yet." });
}
const key = `conversations/${conversationId}/files/${uuid()}.${ext}`;
blobs.set(key, { bytes: file.data, type, name: file.filename });
return json(res, 200, { kind: "file", file_key: key, name: file.filename, chars: ext === "pdf" ? 1834 : file.data.length });
}
const summary = (c) => ({ id: c.id, name: c.name, created_at: c.created_at, updated_at: c.updated_at });
async function api(req, res, url) {
const p = url.pathname.replace(/\/+$/, "") || "/";
const m = req.method;
const body = m === "GET" ? {} : await readBody(req);
const { form } = body;
requests.push({
method: m,
path: url.pathname + url.search,
body: form
? {
...form.fields,
files: Object.fromEntries(
Object.entries(form.files).map(([k, f]) => [k, { filename: f.filename, type: f.type, size: f.data.length }]),
),
}
: body,
});
if (p === "/api/user/me") {
return json(res, 200, { id: "u1", username: "ryan", email: "ryan@example.com", is_admin: true });
}
if (p === "/api/user/refresh") return json(res, 200, { access_token: "mock-access-token" });
if (p === "/api/user/oidc/login" || p === "/api/user/login") return json(res, 200, {});
if (p === "/api/user/admin/users") return json(res, 200, []);
if (p.startsWith("/api/scheduled")) return json(res, 200, []);
if (p === "/api/conversation/stream-query" && m === "POST") {
return streamQuery(req, res, body);
}
if (p === "/api/conversation/query" && m === "POST") {
return json(res, 200, { response: "meow (mock)" });
}
if (p === "/api/conversation/upload" && m === "POST") return upload(res, form);
if (p === "/api/conversation/upload-image") {
return json(res, 200, { image_key: `mock/${uuid()}.png` });
}
if (p.startsWith("/api/conversation/image/")) {
const key = decodeURIComponent(p.slice("/api/conversation/image/".length));
return json(res, 200, { url: blobs.has(key) ? `/__mock/blob/${encodeURIComponent(key)}` : "/favicon.ico" });
}
if (p === "/api/conversation") {
if (m === "POST") return json(res, 200, summary(newConversation()));
const search = (url.searchParams.get("search") ?? "").toLowerCase();
let list = [...conversations.values()]
.filter((c) => c.name.toLowerCase().includes(search))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
.map(summary);
const limit = url.searchParams.get("limit");
if (limit != null) {
const offset = Number(url.searchParams.get("offset") ?? 0);
list = list.slice(offset, offset + Number(limit));
}
return json(res, 200, list);
}
const conv = p.match(/^\/api\/conversation\/([^/]+)$/);
if (conv) {
const c = conversations.get(conv[1]);
if (!c) return json(res, 404, { error: "conversation not found" });
if (m === "DELETE") {
conversations.delete(c.id);
return json(res, 200, { success: true });
}
if (m === "PATCH") {
c.name = body.name ?? c.name;
return json(res, 200, summary(c));
}
return json(res, 200, {
...summary(c),
messages: c.messages.map(({ id, text, speaker, created_at, image_key, tool, label, artifact }) =>
speaker === "tool"
? { id, text, speaker, created_at, image_key, ...(tool ? { tool, label, artifact } : {}) }
: { id, text, speaker, created_at, image_key },
),
});
}
if (p === "/api/artifacts") {
await sleep(300); // long enough to see the panel's loading states
const cid = url.searchParams.get("conversation_id");
return json(
res,
200,
[...artifacts.values()]
.filter((a) => !cid || a.conversation_id === cid)
.map((a) => ({ ...refOf(a), tool: a.tool, message_id: a.message_id, created_at: a.created_at })),
);
}
const art = p.match(/^\/api\/artifacts\/([^/]+)$/);
if (art) {
await sleep(400);
const a = artifacts.get(art[1]);
if (!a) return json(res, 404, { error: "not found" });
if (a.broken) return json(res, 500, { error: "Internal error" });
const { broken, ...artifact } = a;
return json(res, 200, artifact);
}
return json(res, 404, { error: `mock: no route for ${m} ${p}` });
}
function serveStatic(res, pathname) {
const file = path.join(distDir, path.normalize(pathname));
if (file.startsWith(distDir) && fs.existsSync(file) && fs.statSync(file).isFile()) {
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream" });
return fs.createReadStream(file).pipe(res);
}
// SPA catch-all, like app.py.
const index = path.join(distDir, "index.html");
if (!fs.existsSync(index)) {
res.writeHead(500);
return res.end("dist/index.html missing: run `yarn build` first");
}
res.writeHead(200, { "Content-Type": MIME[".html"] });
fs.createReadStream(index).pipe(res);
}
http
.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
try {
if (url.pathname === "/__mock/requests") return json(res, 200, requests);
if (url.pathname.startsWith("/__mock/blob/")) {
const blob = blobs.get(decodeURIComponent(url.pathname.slice("/__mock/blob/".length)));
if (!blob) return json(res, 404, { error: "no blob" });
res.writeHead(200, { "Content-Type": blob.type });
return res.end(blob.bytes);
}
if (url.pathname.startsWith("/api/")) return await api(req, res, url);
return serveStatic(res, decodeURIComponent(url.pathname));
} catch (err) {
console.error(err);
if (!res.headersSent) json(res, 500, { error: String(err) });
}
})
.listen(port, () => console.log(`mock SimbaRAG on http://localhost:${port}`));
+272
View File
@@ -0,0 +1,272 @@
"""Regression tests for complete-turn context budgeting."""
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from blueprints.conversation import context
from blueprints.conversation.context import (
IMAGE_PLACEHOLDER,
build_context,
message_tokens,
trim_history,
)
def call(*ids):
return AIMessage(
content="",
tool_calls=[
{"id": i, "name": "search", "args": {"path": "notes/budget.md"}}
for i in ids
],
)
def result(i, content="found"):
return ToolMessage(content=content, tool_call_id=i)
def cost(messages):
return sum(message_tokens(m) for m in messages)
def test_long_current_turn_keeps_question_and_all_exchanges():
question = HumanMessage(content="Compare my spending with my plan")
messages = [question]
for i in range(8):
messages += [call(str(i)), result(str(i), "data " * 1000)]
out = trim_history(messages, token_budget=3000)
assert out[0] is question
assert len(out) == 17
assert cost(out) <= 3000
assert [m.tool_call_id for m in out if isinstance(m, ToolMessage)] == [
str(i) for i in range(8)
]
assert messages[2].content == "data " * 1000
assert out[1].tool_calls == messages[1].tool_calls
def test_parallel_tool_results_remain_paired():
messages = [
HumanMessage(content="q"),
call("a", "b"),
result("a", "x" * 20000),
result("b", "y" * 20000),
]
out = trim_history(messages, token_budget=700)
assert len(out) == 4
assert out[1].tool_calls == messages[1].tool_calls
assert cost(out) <= 700
def test_drops_whole_turns_and_includes_bounded_excerpts():
messages = []
for i in range(12):
messages += [
HumanMessage(content=f"Question {i}"),
call(str(i)),
result(str(i), "data " * 1000),
AIMessage(content=f"Decision {i}"),
]
question = HumanMessage(content="What did we decide?")
messages.append(question)
out = trim_history(messages, token_budget=3000)
assert isinstance(out[0], SystemMessage)
assert "Decision" in out[0].content
assert "historical data" in out[0].content
assert cost(out) <= 3000
assert out[-1] is question
# Every retained result still has its original call preceding it.
seen = set()
for m in out:
if isinstance(m, AIMessage):
seen.update(c["id"] for c in m.tool_calls)
if isinstance(m, ToolMessage):
assert m.tool_call_id in seen
def test_earlier_images_replaced_current_image_preserved():
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
messages = [
HumanMessage(content=[{"type": "text", "text": "old"}, image]),
AIMessage(content="photo"),
HumanMessage(content=[{"type": "text", "text": "new"}, image]),
]
out = trim_history(messages)
assert out[0].content[1] == {"type": "text", "text": IMAGE_PLACEHOLDER}
assert out[-1].content[1] == image
assert messages[0].content[1] == image
def test_short_history_unchanged():
messages = [HumanMessage(content="hi"), AIMessage(content="hello")]
assert trim_history(messages) == messages
def test_question_that_cannot_fit_fails_without_truncation():
messages = [HumanMessage(content="important request " * 1000)]
with pytest.raises(ValueError, match="current question"):
trim_history(messages, token_budget=100)
assert messages[0].content == "important request " * 1000
def test_system_and_tool_schemas_are_charged_to_budget(monkeypatch):
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 2500)
monkeypatch.setattr(context, "CONTEXT_WINDOW_TOKENS", 10000)
schemas = [{"name": "search", "description": "tool docs " * 100}]
messages = [HumanMessage(content="q"), call("a"), result("a", "result " * 3000)]
out = build_context(messages, "system " * 100, schemas)
assert cost(out) + context.estimate_tokens(schemas) <= 2500
assert out[1] is messages[0]
def test_fixed_overhead_that_cannot_fit_fails(monkeypatch):
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 100)
with pytest.raises(ValueError, match="tool definitions"):
build_context([HumanMessage(content="q")], "system" * 1000, [])
def test_response_and_safety_reserves_reduce_prompt_limit(monkeypatch):
monkeypatch.setattr(context, "CONTEXT_WINDOW_TOKENS", 8000)
monkeypatch.setattr(context, "PROMPT_BUDGET_TOKENS", 16000)
messages = [HumanMessage(content="q"), call("a"), result("a", "data " * 5000)]
out = build_context(messages, "system", [])
assert (
cost(out) + context.estimate_tokens([])
<= 8000 - context.RESPONSE_RESERVE_TOKENS - context.SAFETY_RESERVE_TOKENS
)
def test_cancelled_turn_does_not_replay_incomplete_exchange():
messages = [
HumanMessage(content="old"),
call("a", "b"),
result("a"),
HumanMessage(content="new"),
]
out = trim_history(messages)
assert out == [messages[0], messages[-1]]
def test_orphan_results_are_removed():
messages = [
result("orphan"),
HumanMessage(content="q"),
result("another orphan"),
AIMessage(content="answer"),
]
assert trim_history(messages) == [messages[1], messages[3]]
def test_large_assistant_answer_is_bounded_in_earlier_turn():
messages = [
HumanMessage(content="old"),
AIMessage(content="long " * 10000),
HumanMessage(content="new"),
]
out = trim_history(messages, token_budget=4000)
assert cost(out) <= 4000
assert len(out[1].content) < len(messages[1].content)
assert messages[1].content == "long " * 10000
def test_multiblock_attachment_shares_one_cap():
messages = [
HumanMessage(content=[{"type": "text", "text": "x" * 10000} for _ in range(5)]),
AIMessage(content="ok"),
HumanMessage(content="next"),
]
out = trim_history(messages, token_budget=5000)
assert cost(out) <= 5000
assert sum(len(p["text"]) for p in out[0].content) < 5000
async def test_saved_memories_are_bounded_without_deleting(monkeypatch):
from blueprints.conversation import memory
saved = ["newest preference", "big " * 5000, "older preference"]
async def get_memories(user_id):
return saved
monkeypatch.setattr(memory, "get_memories_for_user", get_memories)
prompt = await memory.build_system_prompt_with_memories("user")
block = prompt.split("USER MEMORIES", 1)[1]
assert "newest preference" in block
assert "older preference" in block
assert "omitted" in block
assert "big big" not in block
assert saved[1] == "big " * 5000
def test_agent_prompt_accounts_for_extra_tool_schemas(monkeypatch):
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
from blueprints.conversation import agents
captured = {}
def create(**kwargs):
captured.update(kwargs)
monkeypatch.setattr(agents, "create_react_agent", create)
extra = {
"type": "function",
"function": {
"name": "extra",
"description": "extra docs",
"parameters": {"type": "object", "properties": {}},
},
}
agents.build_agent(extra_tools=[extra])
assert extra in captured["tools"]
question = HumanMessage(content="hello")
actual = captured["prompt"].invoke({"messages": [question]})
assert actual[-1] is question
# The actual prompt closure includes this schema rather than relying on a
# static reserve that would miss admin-only tools.
seen = {}
def build(messages, system, schemas):
seen["schemas"] = schemas
return messages
monkeypatch.setattr(agents, "build_context", build)
messages = [HumanMessage(content="hello")]
assert captured["prompt"].invoke({"messages": messages}) == messages
assert extra in seen["schemas"]
def test_heavy_current_turn_leaves_room_for_previous_decisions():
question = HumanMessage(content="Apply that plan to my spending")
messages = [
HumanMessage(content="Plan?"),
AIMessage(content="Keep dining under $300."),
question,
call("a"),
result("a", "spending " * 10000),
]
out = trim_history(messages, token_budget=2500)
assert cost(out) <= 2500
assert question in out
assert any(
"Keep dining under $300." in m.content
for m in out
if isinstance(m.content, str)
)
def test_large_tool_arguments_fail_explicitly_instead_of_breaking_pair():
exchange = call("a")
exchange.tool_calls[0]["args"] = {"query": "huge " * 10000}
messages = [HumanMessage(content="q"), exchange, result("a")]
with pytest.raises(ValueError, match="tool calls exceed"):
trim_history(messages, token_budget=300)
assert exchange.tool_calls[0]["args"]["query"] == "huge " * 10000
def test_unicode_tool_result_fits_estimated_budget():
messages = [HumanMessage(content="q"), call("a"), result("a", "猫😺预算" * 10000)]
out = trim_history(messages, token_budget=600)
assert cost(out) <= 600
assert out[0] is messages[0]
assert out[1].tool_calls == messages[1].tool_calls
+282
View File
@@ -0,0 +1,282 @@
"""Tests for conversation file attachments: extraction, /upload and stream-query file_key."""
import io
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import fitz
import pytest
from PIL import Image
from quart import Quart
from quart.datastructures import FileStorage
from quart_jwt_extended import JWTManager, create_access_token
import blueprints.conversation as conv
from utils.file_extract import (
MAX_ATTACHMENT_CHARS,
TRUNCATION_MARKER,
FileExtractionError,
attachment_block,
cap_text,
extract_text,
file_extension,
)
CONV_ID = "11111111-1111-1111-1111-111111111111"
OTHER_ID = "22222222-2222-2222-2222-222222222222"
def _pdf_bytes(text: str) -> bytes:
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), text)
data = doc.tobytes()
doc.close()
return data
def _png_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (4, 4), "orange").save(buf, format="PNG")
return buf.getvalue()
class TestFileExtension:
@pytest.mark.parametrize(
"name,mime,expected",
[
("a.pdf", "application/pdf", "pdf"),
("notes", "text/markdown", "md"),
("notes.md", "application/octet-stream", "md"),
("data.CSV", "", "csv"),
("x.txt", "text/plain; charset=utf-8", "txt"),
("x.markdown", None, "md"),
("x.docx", "application/vnd.openxmlformats", None),
("x.zip", "application/zip", None),
],
)
def test_detects_supported_types(self, name, mime, expected):
assert file_extension(name, mime) == expected
class TestExtractText:
def test_pdf(self):
assert "Simba eats kibble" in extract_text(
_pdf_bytes("Simba eats kibble"), "pdf"
)
def test_corrupt_pdf_raises(self):
with pytest.raises(FileExtractionError):
extract_text(b"not a pdf", "pdf")
def test_text_decodes_utf8_with_replacement(self):
assert extract_text("héllo".encode() + b"\xff", "txt") == "héllo�"
def test_csv_and_md_are_plain_text(self):
assert extract_text(b"\xef\xbb\xbfa,b\n1,2", "csv") == "a,b\n1,2"
assert extract_text(b"# Title", "md") == "# Title"
class TestTruncation:
def test_short_text_untouched(self):
assert cap_text("abc") == "abc"
def test_long_text_capped_with_marker(self):
out = cap_text("x" * (MAX_ATTACHMENT_CHARS + 50))
assert out.endswith(TRUNCATION_MARKER)
assert out.count("x") == MAX_ATTACHMENT_CHARS
def test_attachment_block_wraps_and_caps(self):
block = attachment_block('a"b.txt', "y" * (MAX_ATTACHMENT_CHARS + 1))
assert block.startswith('<attached file "a\'b.txt">\n')
assert block.endswith(f"{TRUNCATION_MARKER}\n</attached>")
class TestFileKeyPrefix:
def test_own_conversation(self):
assert conv._file_key_belongs_to(
f"conversations/{CONV_ID}/files/x.pdf", CONV_ID
)
@pytest.mark.parametrize(
"key",
[
f"conversations/{OTHER_ID}/files/x.pdf",
f"conversations/{CONV_ID}/x.jpg",
f"conversations/{CONV_ID}/files/../../{OTHER_ID}/files/x.pdf",
f"conversations/{CONV_ID}",
None,
],
)
def test_rejects_other_keys(self, key):
assert not conv._file_key_belongs_to(key, CONV_ID)
@pytest.fixture
def app():
app = Quart(__name__)
app.config["JWT_SECRET_KEY"] = "test-secret"
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
JWTManager(app)
app.register_blueprint(conv.conversation_blueprint)
return app
@pytest.fixture
async def headers(app):
async with app.app_context():
token = create_access_token(identity="user-1")
return {"Authorization": f"Bearer {token}"}
def _owned(conversation_id, user_id):
if conversation_id == CONV_ID and user_id == "user-1":
return SimpleNamespace(id=CONV_ID)
return None
@pytest.fixture
def s3():
with (
patch.object(conv, "_get_owned_conversation", AsyncMock(side_effect=_owned)),
patch.object(conv, "s3_upload_file", AsyncMock()) as upload_file,
patch.object(conv, "s3_upload_image", AsyncMock()) as upload_image,
):
yield SimpleNamespace(upload_file=upload_file, upload_image=upload_image)
async def _upload(
app, headers, data: bytes, filename: str, mime: str, conversation_id=CONV_ID
):
return await app.test_client().post(
"/api/conversation/upload",
headers=headers,
form={"conversation_id": conversation_id},
files={
"file": FileStorage(io.BytesIO(data), filename=filename, content_type=mime)
},
)
class TestUploadRoute:
async def test_pdf_is_stored_with_filename(self, app, headers, s3):
resp = await _upload(
app, headers, _pdf_bytes("hello"), "Vet bill.pdf", "application/pdf"
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["kind"] == "file"
assert body["name"] == "Vet bill.pdf"
assert body["chars"] >= len("hello")
assert body["file_key"].startswith(f"conversations/{CONV_ID}/files/")
assert body["file_key"].endswith(".pdf")
_, key, mime, metadata = s3.upload_file.await_args.args
assert key == body["file_key"] and mime == "application/pdf"
assert metadata == {"filename": "Vet%20bill.pdf"}
async def test_markdown_by_extension(self, app, headers, s3):
resp = await _upload(app, headers, b"# hi", "n.md", "application/octet-stream")
body = await resp.get_json()
assert resp.status_code == 200
assert body["chars"] == 4 and body["file_key"].endswith(".md")
async def test_image_goes_through_process_image(self, app, headers, s3):
resp = await _upload(app, headers, _png_bytes(), "cat.png", "image/png")
assert resp.status_code == 200
body = await resp.get_json()
assert body["kind"] == "image"
assert body["image_key"].startswith(f"conversations/{CONV_ID}/")
s3.upload_image.assert_awaited_once()
s3.upload_file.assert_not_awaited()
async def test_unsupported_type_is_415(self, app, headers, s3):
resp = await _upload(app, headers, b"PK\x03\x04", "a.zip", "application/zip")
assert resp.status_code == 415
s3.upload_file.assert_not_awaited()
async def test_unsupported_image_type_is_415(self, app, headers, s3):
resp = await _upload(app, headers, b"GIF89a", "a.gif", "image/gif")
assert resp.status_code == 415
async def test_corrupt_pdf_is_400(self, app, headers, s3):
resp = await _upload(app, headers, b"nope", "a.pdf", "application/pdf")
assert resp.status_code == 400
async def test_pdf_without_text_layer_is_422(self, app, headers, s3):
doc = fitz.open()
doc.new_page() # blank page: no text, like a scan without OCR
resp = await _upload(app, headers, doc.tobytes(), "scan.pdf", "application/pdf")
assert resp.status_code == 422
assert "no text layer" in (await resp.get_json())["error"]
s3.upload_file.assert_not_awaited()
async def test_empty_text_file_is_422(self, app, headers, s3):
resp = await _upload(app, headers, b" \n", "empty.txt", "text/plain")
assert resp.status_code == 422
s3.upload_file.assert_not_awaited()
async def test_extraction_runs_off_the_event_loop(self, app, headers, s3):
calls = []
async def fake_to_thread(fn, *args):
calls.append(fn)
return fn(*args)
with patch.object(conv.asyncio, "to_thread", fake_to_thread):
resp = await _upload(
app, headers, _pdf_bytes("hi"), "a.pdf", "application/pdf"
)
get = AsyncMock(return_value=(b"x", {}))
with patch.object(conv, "s3_get_file", get):
await conv._load_attached_file(f"conversations/{CONV_ID}/files/u.txt")
assert resp.status_code == 200
assert calls == [conv.extract_text, conv.extract_text]
async def test_other_users_conversation_is_404(self, app, headers, s3):
resp = await _upload(
app, headers, b"hi", "a.txt", "text/plain", conversation_id=OTHER_ID
)
assert resp.status_code == 404
s3.upload_file.assert_not_awaited()
async def test_requires_auth(self, app, s3):
resp = await _upload(app, {}, b"hi", "a.txt", "text/plain")
assert resp.status_code == 401
class TestStreamQueryFileKey:
async def test_rejects_file_key_from_other_conversation(self, app, headers, s3):
add = AsyncMock()
with (
patch.object(conv.blueprints.users.models.User, "get", AsyncMock()),
patch.object(conv, "add_message_to_conversation", add),
):
resp = await app.test_client().post(
"/api/conversation/stream-query",
headers=headers,
json={
"query": "summarise",
"conversation_id": CONV_ID,
"file_key": f"conversations/{OTHER_ID}/files/x.pdf",
},
)
assert resp.status_code == 400
add.assert_not_awaited()
async def test_load_attached_file_recovers_name_and_text(self):
get = AsyncMock(return_value=(b"a,b\n1,2", {"filename": "M%C3%A9nage.csv"}))
with patch.object(conv, "s3_get_file", get):
name, text = await conv._load_attached_file(
f"conversations/{CONV_ID}/files/u.csv"
)
assert name == "Ménage.csv"
assert text == "a,b\n1,2"
async def test_load_attached_file_falls_back_to_key_name(self):
get = AsyncMock(return_value=(b"x", {}))
with patch.object(conv, "s3_get_file", get):
name, _ = await conv._load_attached_file(
f"conversations/{CONV_ID}/files/u.txt"
)
assert name == "u.txt"
+220
View File
@@ -0,0 +1,220 @@
"""Tests for the stream-query stall timeout and per-call model metrics."""
import asyncio
import json
import logging
import pytest
from langchain_core.messages import AIMessage, AIMessageChunk
from quart import Quart
from quart_jwt_extended import JWTManager, create_access_token
from tortoise import Tortoise
import blueprints.conversation as conv
from blueprints.conversation.models import Conversation
from blueprints.users.models import User
# Shortened stall limit for the tests (seconds).
LIMIT = 0.3
@pytest.fixture
async def owner():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={
"models": [
"blueprints.users.models",
"blueprints.conversation.models",
"blueprints.artifacts.models",
]
},
)
await Tortoise.generate_schemas()
user = await User.create(username="owner", email="owner@example.com")
conversation = await Conversation.create(name="c", user=user)
yield user, conversation
await Tortoise.close_connections()
class _TimedAgent:
"""Yields (delay_seconds, event) pairs, sleeping before each event."""
def __init__(self, script):
self._script = script
async def astream_events(self, payload, version, config):
for delay, event in self._script:
await asyncio.sleep(delay)
yield event
def _start(run_id):
return {"event": "on_chat_model_start", "run_id": run_id, "name": "llm", "data": {}}
def _chunk(run_id, text):
return {
"event": "on_chat_model_stream",
"run_id": run_id,
"data": {"chunk": AIMessageChunk(content=text)},
}
def _end(run_id, prompt_tokens=None):
usage = (
{
"input_tokens": prompt_tokens,
"output_tokens": 5,
"total_tokens": prompt_tokens + 5,
}
if prompt_tokens
else None
)
return {
"event": "on_chat_model_end",
"run_id": run_id,
"data": {"output": AIMessage(content="", usage_metadata=usage)},
}
def _final(text):
return {
"event": "on_chain_end",
"name": "agent",
"data": {"output": {"messages": [AIMessage(content=text)]}},
}
async def _run(owner, monkeypatch, script, *, response_timeout=60):
user, conversation = owner
monkeypatch.setattr(conv, "MODEL_STALL_TIMEOUT", LIMIT)
monkeypatch.setattr(conv, "get_agent_for", lambda u: _TimedAgent(script))
async def _prompt(user_id):
return "system"
monkeypatch.setattr(conv, "build_system_prompt_with_memories", _prompt)
app = Quart(__name__)
app.config["RESPONSE_TIMEOUT"] = response_timeout
app.config["JWT_SECRET_KEY"] = "test-secret"
JWTManager(app)
app.register_blueprint(conv.conversation_blueprint)
async with app.app_context():
token = create_access_token(identity=str(user.id))
resp = await app.test_client().post(
"/api/conversation/stream-query",
json={"query": "hi", "conversation_id": str(conversation.id)},
headers={"Authorization": f"Bearer {token}"},
)
body = (await resp.get_data()).decode()
return [
json.loads(line[len("data: ") :])
for line in body.splitlines()
if line.startswith("data: {")
]
def test_stall_timeout_only_while_a_model_call_is_in_flight(monkeypatch):
monkeypatch.setattr(conv, "MODEL_STALL_TIMEOUT", 30.0)
assert conv.stall_timeout(None, 100.0) is None
assert conv.stall_timeout(100.0, 110.0) == 20.0
assert conv.stall_timeout(100.0, 200.0) == 1.0 # floor
async def test_active_stream_outlasts_response_timeout(owner, monkeypatch):
script = [
(0, _start("m1")),
(0, _chunk("m1", "<think>")),
(0.15, _chunk("m1", "working")),
(0.15, _chunk("m1", "</think>Answer")),
(0, _end("m1")),
(0, _final("Answer")),
]
frames = await _run(owner, monkeypatch, script, response_timeout=0.1)
assert "error" not in [f["type"] for f in frames]
assert frames[-1] == {"type": "response", "message": "Answer"}
async def test_long_thinking_is_not_a_stall(owner, monkeypatch):
# The whole call takes ~4x the limit, but output never pauses for longer
# than half of it; the old time-to-first-content deadline killed this.
script = [(0, _start("m1"))]
script += [(LIMIT / 2, _chunk("m1", "<think>hmm ")) for _ in range(8)]
script += [
(0, _chunk("m1", "</think>Answer")),
(0, _end("m1")),
(0, _final("Answer")),
]
frames = await _run(owner, monkeypatch, script)
types = [f["type"] for f in frames]
assert "error" not in types
assert frames[-1] == {"type": "response", "message": "Answer"}
async def test_tool_time_between_model_calls_does_not_count(owner, monkeypatch):
tool = {"event": "on_tool_start", "name": "web_search", "run_id": "t1", "data": {}}
tool_end = {
"event": "on_tool_end",
"name": "web_search",
"run_id": "t1",
"data": {},
}
script = [
(0, _start("m1")),
(0, _chunk("m1", "")),
(0, _end("m1")),
(0, tool),
(LIMIT * 3, tool_end), # slow tool, no model call in flight
(0, _start("m2")),
(LIMIT / 2, _chunk("m2", "Done")),
(0, _end("m2")),
(0, _final("Done")),
]
frames = await _run(owner, monkeypatch, script)
assert "error" not in [f["type"] for f in frames]
assert frames[-1]["message"] == "Done"
async def test_silent_model_call_is_a_stall(owner, monkeypatch):
script = [
(0, _start("m1")),
(0, _chunk("m1", "<think>start")),
(LIMIT * 4, _chunk("m1", "never arrives")),
]
frames = await _run(owner, monkeypatch, script)
assert frames[-1]["type"] == "error"
assert frames[-1]["stalled"] is True
async def test_logs_per_call_prompt_tokens(owner, monkeypatch, caplog):
script = [
(0, _start("m1")),
(0, _chunk("m1", "Hi")),
(0, _end("m1", prompt_tokens=27262)),
(0, _final("Hi")),
]
with caplog.at_level(logging.INFO):
await _run(owner, monkeypatch, script)
summaries = [
json.loads(r.getMessage())
for r in caplog.records
if r.getMessage().startswith('{"event": "stream_request"')
]
assert summaries, "stream_request summary not logged"
summary = summaries[-1]
assert summary["max_prompt_tokens"] == 27262
(call,) = summary["model_calls"]
assert call["prompt_tokens"] == 27262
assert call["prefill_ms"] is not None and call["ms"] is not None
+614
View File
@@ -0,0 +1,614 @@
"""Tests for tools returning (text, artifact) via content_and_artifact.
The text half must stay identical to the pre-artifact output (the LLM's view),
and the artifact must match the contract in docs/cowork-ui-plan.md.
"""
import datetime
import json
import os
from unittest.mock import AsyncMock, MagicMock
import pytest
from langchain_core.documents import Document
from langchain_core.messages import ToolMessage
os.environ.setdefault("TAVILY_API_KEY", "dummy")
from blueprints.conversation import agents # noqa: E402
from blueprints.conversation.artifacts_util import ( # noqa: E402
NOTE_MAX_CHARS,
SNIPPET_MAX_CHARS,
format_date_range,
search_documents_artifact,
)
from utils.obsidian_service import ObsidianService # noqa: E402
KIND_DATA_KEYS = {
"mealie.recipe": {
"slug",
"name",
"description",
"servings",
"prep_time",
"cook_time",
"total_time",
"ingredients",
"instructions",
"tags",
},
"mealie.meal_plan": {"start_date", "end_date", "days"},
"mealie.shopping_lists": {"lists"},
"obsidian.note": {"path", "title", "markdown", "tags", "frontmatter"},
"search.documents": {"query", "source", "results"},
}
async def call(tool, args=None) -> ToolMessage:
"""Invoke a tool the way the agent does: with a ToolCall dict."""
msg = await tool.ainvoke(
{"type": "tool_call", "id": "call-1", "name": tool.name, "args": args or {}}
)
assert isinstance(msg, ToolMessage)
return msg
def assert_artifact(artifact, kind):
assert artifact is not None
assert artifact["kind"] == kind
assert isinstance(artifact["title"], str) and artifact["title"]
assert set(artifact["data"]) == KIND_DATA_KEYS[kind]
json.dumps(artifact)
@pytest.fixture
def mealie(monkeypatch):
service = MagicMock()
monkeypatch.setattr(agents, "mealie_enabled", True)
monkeypatch.setattr(agents, "mealie_service", service, raising=False)
monkeypatch.setenv("MEALIE_BASE_URL", "http://mealie.local:9000/")
monkeypatch.delenv("MEALIE_GROUP_SLUG", raising=False)
return service
@pytest.fixture
def obsidian(monkeypatch, tmp_path):
(tmp_path / "seed.md").write_text("# seed")
monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(tmp_path))
monkeypatch.delenv("OBSIDIAN_VAULT_NAME", raising=False)
service = ObsidianService()
monkeypatch.setattr(agents, "obsidian_enabled", True)
monkeypatch.setattr(agents, "obsidian_service", service, raising=False)
return service
# --- Mealie -----------------------------------------------------------------
RECIPE = {
"name": "Chicken Tikka Masala",
"slug": "chicken-tikka-masala",
"description": "Creamy and spiced.",
"ingredients": [{"display": "1 lb chicken"}, {"display": "1 cup yogurt"}],
"instructions": [
{"title": "Marinate", "text": "Mix chicken and yogurt."},
{"title": "", "text": "Cook it."},
],
"prep_time": "20 minutes",
"cook_time": "30 minutes",
"total_time": "",
"servings": "4",
"tags": ["Indian", "Dinner"],
}
RECIPE_TEXT = """**Chicken Tikka Masala**
Creamy and spiced.
Servings: 4
Prep: 20 minutes | Cook: 30 minutes
Ingredients:
- 1 lb chicken
- 1 cup yogurt
Instructions:
1. Mix chicken and yogurt.
2. Cook it.
Tags: Indian, Dinner"""
async def test_mealie_get_recipe(mealie):
mealie.get_recipe = AsyncMock(return_value=RECIPE)
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "chicken-tikka-masala"})
assert msg.content == RECIPE_TEXT
art = msg.artifact
assert_artifact(art, "mealie.recipe")
assert art["title"] == "Chicken Tikka Masala"
assert art["data"]["ingredients"] == ["1 lb chicken", "1 cup yogurt"]
assert art["data"]["instructions"] == [
{"title": "Marinate", "text": "Mix chicken and yogurt."},
{"text": "Cook it."},
]
assert art["source_ref"] == {
"system": "mealie",
"id": "chicken-tikka-masala",
"url": "http://mealie.local:9000/g/home/r/chicken-tikka-masala",
}
async def test_mealie_recipe_url_uses_group_slug(mealie, monkeypatch):
monkeypatch.setenv("MEALIE_GROUP_SLUG", "family")
mealie.get_recipe = AsyncMock(return_value=RECIPE)
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "chicken-tikka-masala"})
assert msg.artifact["source_ref"]["url"].endswith(
"/g/family/r/chicken-tikka-masala"
)
async def test_mealie_get_recipe_error(mealie):
mealie.get_recipe = AsyncMock(side_effect=Exception("404"))
msg = await call(agents.mealie_get_recipe, {"recipe_slug": "nope"})
assert msg.content == "Error fetching recipe: 404"
assert msg.artifact is None
MEAL_PLANS = {
"start_date": "2026-10-12",
"end_date": "2026-10-19",
"total_plans": 3,
"plans_by_date": {
"2026-10-13": [
{
"entry_type": "dinner",
"title": "",
"recipe_name": "Tacos",
"recipe_slug": "tacos",
"note": "",
}
],
"2026-10-12": [
{
"entry_type": "lunch",
"title": "Leftovers",
"recipe_name": "",
"recipe_slug": "",
"note": "from Sunday",
},
{
"entry_type": "dinner",
"title": "",
"recipe_name": "Chicken Tikka Masala",
"recipe_slug": "chicken-tikka-masala",
"note": "",
},
],
},
}
MEAL_PLANS_TEXT = """Meal plans from 2026-10-12 to 2026-10-19 (3 meals):
2026-10-12:
- Lunch: Leftovers
Note: from Sunday
- Dinner: Chicken Tikka Masala
2026-10-13:
- Dinner: Tacos"""
async def test_mealie_meal_plans(mealie):
mealie.get_meal_plans = AsyncMock(return_value=MEAL_PLANS)
msg = await call(agents.mealie_meal_plans, {})
assert msg.content == MEAL_PLANS_TEXT
art = msg.artifact
assert_artifact(art, "mealie.meal_plan")
assert art["title"] == "Meal plan · Oct 12–19"
assert [d["date"] for d in art["data"]["days"]] == ["2026-10-12", "2026-10-13"]
assert art["data"]["days"][0]["meals"] == [
{"entry_type": "lunch", "name": "Leftovers", "note": "from Sunday"},
{
"entry_type": "dinner",
"name": "Chicken Tikka Masala",
"recipe_slug": "chicken-tikka-masala",
},
]
async def test_mealie_meal_plans_empty(mealie):
mealie.get_meal_plans = AsyncMock(
return_value={
"start_date": "2026-10-12",
"end_date": "2026-10-19",
"total_plans": 0,
"plans_by_date": {},
}
)
msg = await call(agents.mealie_meal_plans, {})
assert msg.content == "No meal plans found from 2026-10-12 to 2026-10-19."
assert msg.artifact is None
async def test_mealie_todays_meals(mealie):
mealie.get_todays_meals = AsyncMock(
return_value={
"today": "2026-10-12",
"total_meals": 1,
"meals": [
{
"entry_type": "dinner",
"title": "",
"recipe_name": "Tacos",
"recipe_slug": "tacos",
"note": "extra salsa",
}
],
}
)
msg = await call(agents.mealie_todays_meals)
assert msg.content == (
"Meals planned for today (2026-10-12):\n- Dinner: Tacos\n Note: extra salsa"
)
art = msg.artifact
assert_artifact(art, "mealie.meal_plan")
assert art["title"] == "Today's meals · Oct 12"
assert art["data"] == {
"start_date": "2026-10-12",
"end_date": "2026-10-12",
"days": [
{
"date": "2026-10-12",
"meals": [
{
"entry_type": "dinner",
"name": "Tacos",
"recipe_slug": "tacos",
"note": "extra salsa",
}
],
}
],
}
async def test_mealie_todays_meals_empty(mealie):
mealie.get_todays_meals = AsyncMock(
return_value={"today": "2026-10-12", "total_meals": 0, "meals": []}
)
msg = await call(agents.mealie_todays_meals)
assert msg.content == "No meals planned for today (2026-10-12)."
assert msg.artifact is None
async def test_mealie_shopping_lists(mealie):
mealie.get_shopping_lists = AsyncMock(
return_value={
"total_lists": 1,
"total_unchecked_items": 2,
"shopping_lists": [
{
"name": "Groceries",
"unchecked_items": [
{"name": "Milk", "quantity": 1, "note": "", "food": None},
{"name": "Eggs", "quantity": 12.0, "note": "", "food": None},
],
"checked_items": [
{"name": "Bread", "quantity": 1, "note": "", "food": None}
],
}
],
}
)
msg = await call(agents.mealie_shopping_lists)
assert msg.content == (
"Shopping lists (2 items needed):\n"
"\n**Groceries** (2 items needed)\n"
" - [ ] Milk\n"
" - [ ] Eggs x12.0\n"
" (1 items already checked off)"
)
art = msg.artifact
assert_artifact(art, "mealie.shopping_lists")
assert art["data"]["lists"] == [
{
"name": "Groceries",
"items": [
{"name": "Milk", "quantity": 1, "checked": False},
{"name": "Eggs", "quantity": 12.0, "checked": False},
{"name": "Bread", "quantity": 1, "checked": True},
],
}
]
async def test_mealie_shopping_lists_empty(mealie):
mealie.get_shopping_lists = AsyncMock(
return_value={
"total_lists": 0,
"total_unchecked_items": 0,
"shopping_lists": [],
}
)
msg = await call(agents.mealie_shopping_lists)
assert msg.content == "No shopping lists found."
assert msg.artifact is None
@pytest.mark.parametrize(
"tool,args",
[
(agents.mealie_get_recipe, {"recipe_slug": "x"}),
(agents.mealie_meal_plans, {}),
(agents.mealie_todays_meals, {}),
(agents.mealie_shopping_lists, {}),
],
)
async def test_mealie_not_configured(monkeypatch, tool, args):
monkeypatch.setattr(agents, "mealie_enabled", False)
msg = await call(tool, args)
assert msg.content == (
"Mealie integration is not configured. Please set MEALIE_BASE_URL and "
"MEALIE_API_TOKEN environment variables."
)
assert msg.artifact is None
# --- Obsidian ---------------------------------------------------------------
NOTE = """---
title: Vet visit
date: 2026-10-01
tags: [simba]
---
Simba saw the [[Dr Smith|vet]] today. #simba #health #simba
"""
async def test_obsidian_read_note(obsidian, monkeypatch):
monkeypatch.setenv("OBSIDIAN_VAULT_NAME", "Home Vault")
os.makedirs(os.path.join(obsidian.vault_path, "notes"))
with open(os.path.join(obsidian.vault_path, "notes", "vet visit.md"), "w") as f:
f.write(NOTE)
msg = await call(agents.obsidian_read_note, {"relative_path": "notes/vet visit.md"})
assert msg.content == (
"File: notes/vet visit.md\n\n"
"Frontmatter:\n{'title': 'Vet visit', 'date': datetime.date(2026, 10, 1), "
"'tags': ['simba']}\n\n"
"Content:\nSimba saw the vet today. #simba #health #simba\n\n"
"Tags: simba, health, simba\n"
"Contains 1 wikilinks and 0 embeds"
)
art = msg.artifact
assert_artifact(art, "obsidian.note")
assert art["title"] == "Vet visit"
assert art["data"]["frontmatter"] == {
"title": "Vet visit",
"date": "2026-10-01",
"tags": ["simba"],
}
assert art["data"]["tags"] == ["simba", "health"]
assert art["data"]["markdown"] == "Simba saw the vet today. #simba #health #simba"
assert art["source_ref"] == {
"system": "obsidian",
"path": "notes/vet visit.md",
"url": "obsidian://open?vault=Home%20Vault&file=notes%2Fvet%20visit.md",
}
async def test_obsidian_read_note_caps_markdown_and_omits_url(obsidian):
with open(os.path.join(obsidian.vault_path, "big.md"), "w") as f:
f.write("x" * (NOTE_MAX_CHARS + 500))
msg = await call(agents.obsidian_read_note, {"relative_path": "big.md"})
art = msg.artifact
assert art["title"] == "big"
assert len(art["data"]["markdown"]) <= NOTE_MAX_CHARS
assert art["source_ref"] == {"system": "obsidian", "path": "big.md"}
async def test_obsidian_read_note_missing(obsidian):
msg = await call(agents.obsidian_read_note, {"relative_path": "nope.md"})
assert msg.content == (
"Note not found at 'nope.md'. Please check the path is correct."
)
assert msg.artifact is None
async def test_journal_get_today(obsidian):
path = obsidian.get_daily_note_path()
full = os.path.join(obsidian.vault_path, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
raw = "## Tasks\n- [ ] Feed Simba\n"
with open(full, "w") as f:
f.write(raw)
today = datetime.datetime.now().strftime("%Y-%m-%d")
msg = await call(agents.journal_get_today)
assert msg.content == f"Daily note for {today}:\n\n{raw}"
art = msg.artifact
assert_artifact(art, "obsidian.note")
assert art["title"].startswith("Daily note · ")
assert art["data"]["path"] == path
assert art["data"]["markdown"] == "## Tasks\n- [ ] Feed Simba"
async def test_journal_get_today_missing(obsidian):
today = datetime.datetime.now().strftime("%Y-%m-%d")
msg = await call(agents.journal_get_today)
assert msg.content == (
f"No daily note found for {today}. Use journal_add_task to create one."
)
assert msg.artifact is None
async def test_obsidian_not_configured(monkeypatch):
monkeypatch.setattr(agents, "obsidian_enabled", False)
for tool, args in [
(agents.obsidian_read_note, {"relative_path": "a.md"}),
(agents.obsidian_search_notes, {"query": "a"}),
]:
msg = await call(tool, args)
assert msg.content == (
"Obsidian integration is not configured. Please set "
"OBSIDIAN_VAULT_PATH environment variable."
)
assert msg.artifact is None
msg = await call(agents.journal_get_today)
assert msg.content == "Obsidian integration is not configured."
assert msg.artifact is None
# --- Search -----------------------------------------------------------------
PAPERLESS_DOCS = [
Document(
page_content="Rabies vaccine\nadministered. " + "y" * 600,
metadata={
"source": "paperless",
"created_date": 1760227200.0,
"filename": "vet_invoice.pdf",
"document_type": "Invoice",
},
)
]
async def test_simba_search(monkeypatch):
query_mock = AsyncMock(return_value=("Source: {...}\nContent: x", PAPERLESS_DOCS))
monkeypatch.setattr(agents, "query_vector_store", query_mock)
msg = await call(agents.simba_search, {"query": "vet records"})
assert msg.content == "Source: {...}\nContent: x"
query_mock.assert_awaited_once_with(query="vet records", source="paperless")
art = msg.artifact
assert_artifact(art, "search.documents")
assert art["title"] == "Search: vet records"
assert art["data"]["source"] == "paperless"
(result,) = art["data"]["results"]
assert result["title"] == "vet_invoice"
assert result["filename"] == "vet_invoice.pdf"
assert result["document_type"] == "Invoice"
assert len(result["date"]) == 10
assert result["snippet"].startswith("Rabies vaccine administered. ")
assert len(result["snippet"]) <= SNIPPET_MAX_CHARS
async def test_simba_search_empty(monkeypatch):
monkeypatch.setattr(agents, "query_vector_store", AsyncMock(return_value=("", [])))
msg = await call(agents.simba_search, {"query": "nothing"})
assert msg.content == ""
assert msg.artifact is None
async def test_obsidian_search_notes(obsidian, monkeypatch):
docs = [
Document(
page_content="Simba's vet is Dr Smith.",
metadata={
"source": "obsidian",
"filepath": os.path.join(obsidian.vault_path, "pets", "simba.md"),
"tags": ["simba"],
"created_at": "2026-01-02T10:00:00",
},
)
]
monkeypatch.setattr(
agents, "query_vector_store", AsyncMock(return_value=("serialized", docs))
)
msg = await call(agents.obsidian_search_notes, {"query": "vet"})
assert msg.content == "serialized"
art = msg.artifact
assert_artifact(art, "search.documents")
assert art["data"]["results"] == [
{
"snippet": "Simba's vet is Dr Smith.",
"title": "simba",
"path": "pets/simba.md",
"date": "2026-01-02",
}
]
async def test_obsidian_search_notes_error(obsidian, monkeypatch):
monkeypatch.setattr(
agents, "query_vector_store", AsyncMock(side_effect=Exception("db down"))
)
msg = await call(agents.obsidian_search_notes, {"query": "vet"})
assert msg.content == "Error searching Obsidian notes: db down"
assert msg.artifact is None
def test_search_documents_artifact_empty():
assert search_documents_artifact("q", "paperless", []) is None
@pytest.mark.parametrize(
"start,end,expected",
[
("2026-10-12", "2026-10-19", "Oct 12–19"),
("2026-10-28", "2026-11-03", "Oct 28–Nov 3"),
("2026-12-28", "2027-01-03", "Dec 28, 2026–Jan 3, 2027"),
("2026-10-12", "2026-10-12", "Oct 12"),
("bad", "2026-10-12", "bad–2026-10-12"),
],
)
def test_format_date_range(start, end, expected):
assert format_date_range(start, end) == expected
async def test_obsidian_note_line_range_retains_full_artifact(obsidian):
with open(os.path.join(obsidian.vault_path, "range.md"), "w") as f:
f.write("first\nsecond\nthird\nfourth")
msg = await call(
agents.obsidian_read_note,
{"relative_path": "range.md", "start_line": 2, "max_lines": 2},
)
assert "Content lines 2-3 of 4:\nsecond\nthird\n\n" in msg.content
assert "first" not in msg.content
assert msg.artifact["data"]["markdown"] == "first\nsecond\nthird\nfourth"
@pytest.mark.parametrize(
"args", [{"start_line": 0}, {"max_lines": 0}, {"start_line": 99}]
)
async def test_obsidian_note_invalid_line_range(obsidian, args):
with open(os.path.join(obsidian.vault_path, "range.md"), "w") as f:
f.write("first\nsecond")
msg = await call(agents.obsidian_read_note, {"relative_path": "range.md", **args})
assert msg.artifact is None
assert "positive integers" in msg.content or "beyond the end" in msg.content
+471
View File
@@ -0,0 +1,471 @@
"""Tests for the YNAB tools' content_and_artifact output.
`ensure_fresh` and the `_query` DB helper are mocked; the fake answers each
query by matching a fragment of its SQL.
"""
import json
import re
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
import pytest
from langchain_core.messages import ToolMessage
from blueprints.ynab import tools
KIND_DATA_KEYS = {
"ynab.summary": {
"month",
"ready_to_assign",
"income",
"assigned",
"spent",
"net_worth",
"overspent",
},
"ynab.transactions": {"start_date", "end_date", "count", "total", "transactions"},
"ynab.spending_report": {"start_date", "end_date", "group_by", "rows", "total"},
"ynab.category_budget": {"month", "groups"},
}
async def call(tool, args=None) -> ToolMessage:
"""Invoke a tool the way the agent does: with a ToolCall dict."""
msg = await tool.ainvoke(
{"type": "tool_call", "id": "call-1", "name": tool.name, "args": args or {}}
)
assert isinstance(msg, ToolMessage)
return msg
def assert_artifact(artifact, kind, optional=()):
assert artifact is not None
assert artifact["kind"] == kind
assert isinstance(artifact["title"], str) and artifact["title"]
assert KIND_DATA_KEYS[kind] <= set(artifact["data"])
assert set(artifact["data"]) <= KIND_DATA_KEYS[kind] | set(optional)
json.dumps(artifact)
def fake_db(monkeypatch, responses: dict, synced=True):
"""Route `_query` calls to `responses` by SQL fragment; honour LIMIT."""
calls = []
async def fake_query(sql, *params):
calls.append((sql, params))
for fragment, rows in responses.items():
if fragment in sql:
limit = re.search(r"LIMIT (\d+)", sql)
return rows[: int(limit.group(1))] if limit else rows
raise AssertionError(f"unexpected query: {sql}")
async def fake_ensure_fresh():
return SimpleNamespace(budget_name="Home") if synced else None
monkeypatch.setattr(tools, "_query", fake_query)
monkeypatch.setattr(tools, "ensure_fresh", fake_ensure_fresh)
monkeypatch.setattr(tools, "_today", lambda: date(2026, 10, 12))
return calls
# --- ynab_budget_summary ----------------------------------------------------
SUMMARY_DB = {
"FROM ynab_months": [
{
"to_be_budgeted": Decimal("120.50"),
"income": Decimal("5000.00"),
"budgeted": Decimal("4800.00"),
"age_of_money": 42,
}
],
"mc.balance < 0": [{"name": "Dining Out", "balance": Decimal("-23.10")}],
"AS spent": [{"spent": Decimal("3100.25")}],
"GROUP BY on_budget": [
{"on_budget": True, "total": Decimal("10000.00")},
{"on_budget": False, "total": Decimal("-2500.00")},
],
}
async def test_budget_summary(monkeypatch):
fake_db(monkeypatch, SUMMARY_DB)
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
assert msg.content == (
"YNAB budget 'Home' for 2026-10:\n"
"Ready to Assign: $120.50\n"
"Income: $5,000.00\n"
"Assigned: $4,800.00\n"
"Spent: $3,100.25\n"
"Age of money: 42 days\n"
"Overspent: Dining Out (-$23.10)\n"
"Net worth: $7,500.00 (budget accounts $10,000.00, "
"tracking accounts -$2,500.00)"
)
art = msg.artifact
assert_artifact(art, "ynab.summary", optional={"age_of_money"})
assert art["title"] == "Budget · October 2026"
assert art["data"] == {
"month": "2026-10",
"ready_to_assign": 120.5,
"income": 5000.0,
"assigned": 4800.0,
"spent": 3100.25,
"age_of_money": 42,
"net_worth": 7500.0,
"overspent": [{"name": "Dining Out", "available": -23.1}],
}
async def test_budget_summary_without_age_of_money(monkeypatch):
db = dict(SUMMARY_DB)
db["FROM ynab_months"] = [
{**SUMMARY_DB["FROM ynab_months"][0], "age_of_money": None}
]
fake_db(monkeypatch, db)
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
assert "Age of money" not in msg.content
assert "age_of_money" not in msg.artifact["data"]
async def test_budget_summary_no_month_data(monkeypatch):
fake_db(monkeypatch, {"FROM ynab_months": []})
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
assert msg.content == "No YNAB budget data for 2026-10."
assert msg.artifact is None
# --- ynab_search_transactions -----------------------------------------------
def _txn(i, amount, **extra):
return {
"id": f"t{i}",
"date": date(2026, 10, 12 - i % 10),
"amount": Decimal(amount),
"memo": None,
"approved": True,
"account_name": "Checking",
"payee_name": "Costco",
"category_name": "Groceries",
**extra,
}
TXNS = [
_txn(0, "-54.20", memo="milk"),
_txn(1, "-12.00", approved=False, payee_name=None, category_name=None),
_txn(2, "100.00"),
]
TXN_TOTALS = {
"count(*) AS n": [
{"n": 3, "outflow": Decimal("-66.20"), "inflow": Decimal("100.00")}
],
}
async def test_search_transactions(monkeypatch):
calls = fake_db(monkeypatch, {**TXN_TOTALS, "SELECT * FROM lines": TXNS})
msg = await call(tools.ynab_search_transactions, {"limit": 2})
assert msg.content == (
"Found 3 transactions from 2026-09-12 to 2026-10-12. "
"Outflows -$66.20, inflows $100.00.\n"
"- 2026-10-12: Costco -$54.20 (Groceries; Checking) — milk\n"
"- 2026-10-11: Unknown payee -$12.00 (Uncategorized; Checking) [unapproved]\n"
"(Showing 2 most recent of 3)"
)
assert "LIMIT 500" in calls[-1][0]
art = msg.artifact
assert_artifact(art, "ynab.transactions")
assert art["title"] == "Transactions · Sep 12–Oct 12"
data = art["data"]
assert data["start_date"] == "2026-09-12"
assert data["end_date"] == "2026-10-12"
assert data["count"] == 3
assert data["total"] == pytest.approx(33.8)
# the artifact keeps every row, not just the `limit` shown to the model
assert len(data["transactions"]) == 3
assert data["transactions"][0] == {
"date": "2026-10-12",
"payee": "Costco",
"category": "Groceries",
"account": "Checking",
"memo": "milk",
"amount": -54.2,
}
assert "memo" not in data["transactions"][1]
async def test_search_transactions_caps_artifact_rows(monkeypatch):
many = [_txn(i, "-1.00") for i in range(600)]
totals = {"count(*) AS n": [{"n": 600, "outflow": Decimal("-600"), "inflow": 0}]}
fake_db(monkeypatch, {**totals, "SELECT * FROM lines": many})
msg = await call(tools.ynab_search_transactions, {"payee": "costco"})
assert msg.content.endswith("(Showing 20 most recent of 600)")
assert len(msg.artifact["data"]["transactions"]) == tools.ARTIFACT_MAX_TRANSACTIONS
assert msg.artifact["data"]["start_date"] is None
assert msg.artifact["title"] == "Transactions · through 2026-10-12"
async def test_search_transactions_empty(monkeypatch):
fake_db(monkeypatch, {"count(*) AS n": [{"n": 0, "outflow": 0, "inflow": 0}]})
msg = await call(tools.ynab_search_transactions, {})
assert msg.content == "No transactions found from 2026-09-12 to 2026-10-12."
assert msg.artifact is None
async def test_search_transactions_bad_date(monkeypatch):
fake_db(monkeypatch, {})
msg = await call(tools.ynab_search_transactions, {"start_date": "nope"})
assert msg.content == "Dates must be YYYY-MM-DD."
assert msg.artifact is None
# --- ynab_spending_report ---------------------------------------------------
SPENDING_ROWS = [
{"key": "Groceries", "spent": Decimal("400.00"), "n": 10},
{"key": "Dining Out", "spent": Decimal("150.50"), "n": 5},
{"key": "Gas", "spent": Decimal("60.00"), "n": 2},
]
async def test_spending_report(monkeypatch):
fake_db(
monkeypatch,
{
"GROUP BY 1": SPENDING_ROWS,
"AS outflows": [
{"outflows": Decimal("710.50"), "inflows": Decimal("100.00")}
],
},
)
msg = await call(tools.ynab_spending_report, {"top": 2})
assert msg.content == (
"Spending by category from 2026-10-01 to 2026-10-12:\n"
"- Total spending (outflows): $710.50\n"
"- Inflows (deposits into budget accounts, not spending): $100.00\n"
"- Groceries: $400.00 (10 txns)\n"
"- Dining Out: $150.50 (5 txns)\n"
"- 1 others: $60.00"
)
art = msg.artifact
assert_artifact(art, "ynab.spending_report")
assert art["title"] == "Spending by category · Oct 1–12"
assert art["data"] == {
"start_date": "2026-10-01",
"end_date": "2026-10-12",
"group_by": "category",
"rows": [
{"key": "Groceries", "amount": 400.0, "count": 10},
{"key": "Dining Out", "amount": 150.5, "count": 5},
{"key": "Gas", "amount": 60.0, "count": 2},
],
"total": 710.5,
}
async def test_spending_report_by_month(monkeypatch):
rows = [
{"key": "2026-09", "spent": Decimal("100"), "n": 1},
{"key": "2026-10", "spent": Decimal("200"), "n": 2},
]
fake_db(
monkeypatch,
{
"GROUP BY 1": rows,
"AS outflows": [
{"outflows": Decimal("400.00"), "inflows": Decimal("100.00")}
],
},
)
msg = await call(
tools.ynab_spending_report,
{"group_by": "month", "start_date": "2026-09-01"},
)
assert msg.content.endswith("Monthly average: $200.00")
assert msg.artifact["data"]["total"] == 400.0
assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"]
async def test_spending_report_empty_and_invalid(monkeypatch):
fake_db(monkeypatch, {"GROUP BY 1": []})
msg = await call(tools.ynab_spending_report, {})
assert msg.content == "No spending found from 2026-10-01 to 2026-10-12."
assert msg.artifact is None
msg = await call(tools.ynab_spending_report, {"group_by": "vibes"})
assert msg.content == (
"group_by must be one of: category, category_group, payee, account, month."
)
assert msg.artifact is None
# --- ynab_category_budget ---------------------------------------------------
def _cat(group, name, budgeted, activity, balance, goal=None):
return {
"group_name": group,
"name": name,
"goal_target": Decimal(goal) if goal else None,
"budgeted": Decimal(budgeted),
"activity": Decimal(activity),
"balance": Decimal(balance),
}
async def test_category_budget(monkeypatch):
rows = [
_cat("Bills", "Rent", "2000", "-2000", "0"),
_cat("Bills", "Unused", "0", "0", "0"),
_cat("Food", "Dining Out", "100", "-123.10", "-23.10", goal="150"),
]
fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows})
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
assert msg.content == (
"Categories for 2026-10:\n"
"\nBills\n"
" Rent: assigned $2,000.00, activity -$2,000.00, available $0.00\n"
"\nFood\n"
" Dining Out: assigned $100.00, activity -$123.10, available -$23.10, "
"goal $150.00 [OVERSPENT]"
)
art = msg.artifact
assert_artifact(art, "ynab.category_budget")
assert art["title"] == "Category budget · October 2026"
assert art["data"] == {
"month": "2026-10",
"groups": [
{
"name": "Bills",
"categories": [
{
"name": "Rent",
"assigned": 2000.0,
"activity": -2000.0,
"available": 0.0,
}
],
},
{
"name": "Food",
"categories": [
{
"name": "Dining Out",
"assigned": 100.0,
"activity": -123.1,
"available": -23.1,
"goal_target": 150.0,
}
],
},
],
}
async def test_category_budget_all_zero_rows(monkeypatch):
rows = [_cat("Bills", "Unused", "0", "0", "0")]
fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows})
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
assert msg.content == "Categories for 2026-10:"
assert msg.artifact is None
async def test_category_budget_empty(monkeypatch):
fake_db(monkeypatch, {"FROM ynab_month_categories mc": []})
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
assert msg.content == "No YNAB category data for 2026-10."
assert msg.artifact is None
# --- shared paths -----------------------------------------------------------
ARTIFACT_TOOLS = [
tools.ynab_budget_summary,
tools.ynab_search_transactions,
tools.ynab_spending_report,
tools.ynab_category_budget,
]
@pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name)
async def test_not_synced(monkeypatch, tool):
fake_db(monkeypatch, {}, synced=False)
msg = await call(tool)
assert msg.content == tools.NOT_SYNCED
assert msg.artifact is None
@pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name)
async def test_db_error(monkeypatch, tool):
fake_db(monkeypatch, {})
async def boom(sql, *params):
raise RuntimeError("db down")
monkeypatch.setattr(tools, "_query", boom)
msg = await call(tool)
assert msg.content == f"YNAB error in {tool.name}: RuntimeError: db down"
assert msg.artifact is None
def test_text_only_tools_unchanged():
assert tools.ynab_account_balances.response_format == "content"
assert tools.ynab_recent_changes.response_format == "content"
async def test_spending_report_omits_nonpositive_groups(monkeypatch):
rows = SPENDING_ROWS + [
{"key": "Uncategorized", "spent": Decimal("-5000"), "n": 1},
{"key": "Fully refunded", "spent": Decimal("0"), "n": 2},
]
fake_db(
monkeypatch,
{
"GROUP BY 1": rows,
"AS outflows": [
{"outflows": Decimal("710.50"), "inflows": Decimal("5100.00")}
],
},
)
msg = await call(tools.ynab_spending_report, {})
assert "Total spending (outflows): $710.50" in msg.content
assert "not spending): $5,100.00" in msg.content
assert "Uncategorized" not in msg.content
assert "Fully refunded" not in msg.content
assert [r["key"] for r in msg.artifact["data"]["rows"]] == [
r["key"] for r in SPENDING_ROWS
]
assert msg.artifact["data"]["total"] == 710.5
+62
View File
@@ -0,0 +1,62 @@
"""Text extraction for files attached to a conversation (PDF, txt, md, csv)."""
import os
import fitz
# Extension -> canonical MIME type for the text-like files we accept.
FILE_TYPES = {
"pdf": "application/pdf",
"txt": "text/plain",
"md": "text/markdown",
"csv": "text/csv",
}
_MIME_TO_EXT = {mime: ext for ext, mime in FILE_TYPES.items()}
_MIME_TO_EXT["text/x-markdown"] = "md"
MAX_ATTACHMENT_CHARS = 12_000
TRUNCATION_MARKER = "[truncated]"
class FileExtractionError(Exception):
pass
def file_extension(filename: str | None, content_type: str | None) -> str | None:
"""Return the supported extension for an upload, or None if unsupported.
Matches on the MIME type first, then falls back to the filename extension
(browsers often send .md and .csv as application/octet-stream).
"""
mime = (content_type or "").split(";")[0].strip().lower()
if mime in _MIME_TO_EXT:
return _MIME_TO_EXT[mime]
ext = os.path.splitext(filename or "")[1].lstrip(".").lower()
if ext == "markdown":
ext = "md"
return ext if ext in FILE_TYPES else None
def extract_text(data: bytes, ext: str) -> str:
"""Extract plain text from file bytes. Text files decode as UTF-8 with replacement."""
if ext == "pdf":
try:
with fitz.open(stream=data, filetype="pdf") as doc:
return "\n".join(page.get_text() for page in doc).strip()
except Exception as e:
raise FileExtractionError(f"Could not read PDF: {e}") from e
if ext in FILE_TYPES:
return data.decode("utf-8", errors="replace").lstrip("")
raise FileExtractionError(f"Unsupported file type: {ext}")
def cap_text(text: str, limit: int = MAX_ATTACHMENT_CHARS) -> str:
if len(text) <= limit:
return text
return f"{text[:limit]}\n{TRUNCATION_MARKER}"
def attachment_block(name: str, text: str) -> str:
"""Wrap (capped) file text for the user turn sent to the agent."""
safe_name = name.replace('"', "'")
return f'<attached file "{safe_name}">\n{cap_text(text)}\n</attached>'
+25
View File
@@ -61,3 +61,28 @@ async def delete_image(key: str) -> None:
async with _get_client() as client:
await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)
logging.info(f"Deleted image from S3: {key}")
async def upload_file(
file_bytes: bytes, key: str, content_type: str, metadata: dict[str, str]
) -> str:
"""Store a non-image object. Metadata values must be ASCII (S3 headers)."""
async with _get_client() as client:
await client.put_object(
Bucket=S3_BUCKET_NAME,
Key=key,
Body=file_bytes,
ContentType=content_type,
Metadata=metadata,
)
logging.info(f"Uploaded file to S3: {key}")
return key
async def get_file(key: str) -> tuple[bytes, dict[str, str]]:
"""Return an object's bytes and its user metadata."""
async with _get_client() as client:
response = await client.get_object(Bucket=S3_BUCKET_NAME, Key=key)
body = await response["Body"].read()
metadata = response.get("Metadata", {}) or {}
return body, metadata