Merge main into spending report totals and preserve artifact output
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'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't load this conversation'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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`));
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>'
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user