13 Commits

Author SHA1 Message Date
Ryan Chen
142fac3a84 Switch image analysis from Ollama to llama-server
Use the same llama-server (OpenAI-compatible API) for vision analysis
that the main agent uses, with OpenAI fallback. Sends images as base64
in the standard OpenAI vision message format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:06:51 -04:00
Ryan Chen
0415610d64 Add image upload and vision analysis to Ask Simba chat
Users can now attach images in the web chat for Simba to analyze using
Ollama's gemma3 vision model. Images are stored in Garage (S3-compatible)
and displayed in chat history.

Also fixes aerich migration config by extracting TORTOISE_CONFIG into a
standalone config/db.py module, removing the stale aerich_config.py, and
adding missing MODELS_STATE to migration 3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:03:19 -04:00
Ryan Chen
ac9c821ec7 Remove client-side transaction limit from YNAB service
The get_transactions() method was truncating results to 50 by default,
causing incomplete transaction data. The YNAB API returns all matching
transactions in a single response, so this limit was unnecessary and
caused count/total inconsistencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:46:51 -04:00
ryan
0f88d211de Add PWA support for install-to-home-screen
Adds manifest.json, service worker with static asset caching,
resized cat icons, and meta tags for iOS/Android installability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:16:27 -04:00
ryan
6917f331d8 Fix circular import in email helpers
Move blueprints.email.helpers import from module-level to inside the
endpoint functions that use it, breaking the circular dependency chain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 16:54:48 -04:00
ryan
6a7b1369ad Add email channel via Mailgun for Ask Simba
Users can now receive a unique email address (ask+<token>@domain) and
interact with Simba by sending emails. Inbound emails hit a Mailgun
webhook, are authenticated via HMAC token lookup, processed through the
LangChain agent, and replied to via the Mailgun API.

- Extract shared SIMBA_SYSTEM_PROMPT to blueprints/conversation/prompts.py
- Add email_enabled and email_hmac_token fields to User model
- Create blueprints/email with webhook, signature validation, rate limiting
- Add admin endpoints to enable/disable email per user
- Update AdminPanel with Email column, toggle, and copy-address button
- Add Mailgun env vars to .env.example
- Include database migration for new fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 16:21:18 -04:00
ryan
4621755c54 Make cat icons even bigger across all views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 16:05:53 -04:00
ryan
b6cd4e85f0 Fix mobile viewport scroll and enlarge cat icons
Use 100dvh for proper mobile browser chrome handling and increase
cat icon sizes across sidebar, mobile header, and empty state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 16:03:40 -04:00
ryan
30d7f0a060 Fix YNAB transaction fetching for SDK v2 compatibility
- Convert date strings to datetime.date objects before passing to API (strict Pydantic validation rejects strings)
- Use txn.var_date instead of txn.date (renamed in SDK v2 to avoid Python builtin conflict)
- Migrate BudgetsApi → PlansApi and update method names accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:54:20 -04:00
ryan
da9b52dda1 Add Claude.ai-style homepage with centered empty state
Show centered cat icon + "Ask me anything" + input when no messages
exist. Transition to scrollable messages + bottom input once chat
starts. Auto-create a conversation on first message if none selected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 09:47:37 -04:00
ryan
d1cb55ff1a Frontend revamp: Animal Crossing × Claude design with shadcn components
- New palette: deep nook green sidebar, sage user bubbles, warm cream answer cards
- shadcn-style UI primitives: Button (CVA variants), Textarea, Input, Badge, Table
- cn() utility (clsx + tailwind-merge)
- lucide-react icons throughout (no more text-only buttons)
- Simba mode: custom CSS toggle switch
- Send button: circular amber button with arrow icon
- AnswerBubble: amber gradient accent bar, loading dots animation
- QuestionBubble: sage green pill with rounded-3xl
- ToolBubble: centered leaf-green badge pill
- ConversationList: active item highlighting, proper selectedId prop
- Sidebar: collapsible with PanelLeftClose/Open icons, icon-only collapsed state
- LoginScreen: decorative background blobs, refined rounded card
- AdminPanel: proper icon buttons, leaf-green save confirmation
- Fonts: Playfair Display (brand) + Nunito 800 weight added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 09:22:34 -04:00
ryan
53b2b3b366 Add admin panel and fix simba mode response display
- Add /me, /admin/users, and WhatsApp link/unlink endpoints
- Add AdminPanel component with user management UI
- Add userService methods for admin API calls
- Fix simba mode so cat responses appear in the message list
- Fetch userinfo endpoint for groups on OIDC callback (Authelia compat)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 09:06:59 -04:00
ryan
03c7e0c951 Fix double space in daily note header template
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 08:27:49 -05:00
48 changed files with 3819 additions and 3470 deletions

View File

@@ -70,6 +70,17 @@ TWILIO_SIGNATURE_VALIDATION=true
# WHATSAPP_RATE_LIMIT_MAX=10
# WHATSAPP_RATE_LIMIT_WINDOW=60
# Mailgun Configuration (Email channel)
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
MAILGUN_WEBHOOK_SIGNING_KEY=
EMAIL_HMAC_SECRET=
# Rate limiting: max emails per window (default: 5 per 300 seconds)
# EMAIL_RATE_LIMIT_MAX=5
# EMAIL_RATE_LIMIT_WINDOW=300
# Set to false to disable Mailgun signature validation in development
MAILGUN_SIGNATURE_VALIDATION=true
# Obsidian Configuration (headless sync)
# Auth token from Obsidian account (Settings → Account → API token)
OBSIDIAN_AUTH_TOKEN=your-obsidian-auth-token

22
app.py
View File

@@ -11,7 +11,9 @@ import blueprints.conversation.logic
import blueprints.rag
import blueprints.users
import blueprints.whatsapp
import blueprints.email
import blueprints.users.models
from config.db import TORTOISE_CONFIG
from main import consult_simba_oracle
# Load environment variables
@@ -27,6 +29,7 @@ app = Quart(
)
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY")
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit
jwt = JWTManager(app)
# Register blueprints
@@ -34,26 +37,9 @@ app.register_blueprint(blueprints.users.user_blueprint)
app.register_blueprint(blueprints.conversation.conversation_blueprint)
app.register_blueprint(blueprints.rag.rag_blueprint)
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
app.register_blueprint(blueprints.email.email_blueprint)
# Database configuration with environment variable support
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgres://raggr:raggr_dev_password@localhost:5432/raggr"
)
TORTOISE_CONFIG = {
"connections": {"default": DATABASE_URL},
"apps": {
"models": {
"models": [
"blueprints.conversation.models",
"blueprints.users.models",
"aerich.models",
]
},
},
}
# Initialize Tortoise ORM with lifecycle hooks
@app.while_serving
async def lifespan():

View File

@@ -1,13 +1,19 @@
import datetime
import json
import logging
import uuid
from quart import Blueprint, jsonify, make_response, request
from quart import Blueprint, Response, jsonify, make_response, request
from quart_jwt_extended import (
get_jwt_identity,
jwt_refresh_token_required,
)
import blueprints.users.models
from utils.image_process import analyze_user_image
from utils.image_upload import ImageValidationError, process_image
from utils.s3_client import get_image as s3_get_image
from utils.s3_client import upload_image as s3_upload_image
from .agents import main_agent
from .logic import (
@@ -20,71 +26,18 @@ from .models import (
PydConversation,
PydListConversation,
)
from .prompts import SIMBA_SYSTEM_PROMPT
conversation_blueprint = Blueprint(
"conversation_api", __name__, url_prefix="/api/conversation"
)
_SYSTEM_PROMPT = """You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
SIMBA FACTS (as of January 2026):
- Name: Simba
- Species: Feline (Domestic Short Hair / American Short Hair)
- Sex: Male, Neutered
- Date of Birth: August 8, 2016 (approximately 9 years 5 months old)
- Color: Orange
- Current Weight: 16 lbs (as of 1/8/2026)
- Owner: Ryan Chen
- Location: Long Island City, NY
- Veterinarian: Court Square Animal Hospital
Medical Conditions:
- Hypertrophic Cardiomyopathy (HCM): Diagnosed 12/11/2025. Concentric left ventricular hypertrophy with no left atrial dilation. Grade II-III/VI systolic heart murmur. No cardiac medications currently needed. Must avoid Domitor, acepromazine, and ketamine during anesthesia.
- Dental Issues: Prior extraction of teeth 307 and 407 due to resorption. Tooth 107 extracted on 1/8/2026. Early resorption lesions present on teeth 207, 309, and 409.
Recent Medical Events:
- 1/8/2026: Dental cleaning and tooth 107 extraction. Prescribed Onsior for 3 days. Oravet sealant applied.
- 12/11/2025: Echocardiogram confirming HCM diagnosis. Pre-op bloodwork was normal.
- 12/1/2025: Visited for decreased appetite/nausea. Received subcutaneous fluids and Cerenia.
Diet & Lifestyle:
- Diet: Hill's I/D wet and dry food
- Supplements: Plaque Off
- Indoor only cat, only pet in the household
Upcoming Appointments:
- Rabies Vaccine: Due 2/19/2026
- Routine Examination: Due 6/1/2026
- FVRCP-3yr Vaccine: Due 10/2/2026
IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.
BUDGET & FINANCE (YNAB Integration):
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
- Use ynab_budget_summary for overall budget health and status questions
- Use ynab_search_transactions to find specific purchases or spending at particular stores
- Use ynab_category_spending to analyze spending by category for a month
- Use ynab_insights to provide spending trends, patterns, and recommendations
Always use these tools when asked about budgets, spending, transactions, or financial health.
NOTES & RESEARCH (Obsidian Integration):
You have access to Ryan's Obsidian vault through the Obsidian integration. When users ask about research, personal notes, or information that might be stored in markdown files, use the appropriate Obsidian tools:
- Use obsidian_search_notes to search through your vault for relevant information
- Use obsidian_read_note to read the full content of a specific note by path
- Use obsidian_create_note to save new findings, ideas, or research to your vault
- Use obsidian_create_task to create task notes with due dates
Always use these tools when users ask about notes, research, ideas, tasks, or when you want to save information for future reference.
DAILY JOURNAL (Task Tracking):
You have access to Ryan's daily journal notes. Each note lives at journal/YYYY/YYYY-MM-DD.md and has two sections: tasks and log.
- Use journal_get_today to read today's full daily note (tasks + log)
- Use journal_get_tasks to list tasks (done/pending) for today or a specific date
- Use journal_add_task to add a new task to today's (or a given date's) note
- Use journal_complete_task to check off a task as done
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete."""
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
def _build_messages_payload(conversation, query_text: str) -> list:
def _build_messages_payload(
conversation, query_text: str, image_description: str | None = None
) -> list:
recent_messages = (
conversation.messages[-10:]
if len(conversation.messages) > 10
@@ -93,8 +46,19 @@ def _build_messages_payload(conversation, query_text: str) -> list:
messages_payload = [{"role": "system", "content": _SYSTEM_PROMPT}]
for msg in recent_messages[:-1]: # Exclude the message we just added
role = "user" if msg.speaker == "user" else "assistant"
messages_payload.append({"role": role, "content": msg.text})
messages_payload.append({"role": "user", "content": query_text})
text = msg.text
if msg.image_key and role == "user":
text = f"[User sent an image]\n{text}"
messages_payload.append({"role": role, "content": text})
# Build the current user message with optional image description
if image_description:
content = f"[Image analysis: {image_description}]"
if query_text:
content = f"{query_text}\n\n{content}"
else:
content = query_text
messages_payload.append({"role": "user", "content": content})
return messages_payload
@@ -129,6 +93,58 @@ async def query():
return jsonify({"response": message})
@conversation_blueprint.post("/upload-image")
@jwt_refresh_token_required
async def upload_image():
current_user_uuid = get_jwt_identity()
await blueprints.users.models.User.get(id=current_user_uuid)
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
file_bytes = file.read()
content_type = file.content_type or "image/jpeg"
try:
processed_bytes, output_content_type = process_image(file_bytes, content_type)
except ImageValidationError as e:
return jsonify({"error": str(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(
{
"image_key": key,
"image_url": f"/api/conversation/image/{key}",
}
)
@conversation_blueprint.get("/image/<path:image_key>")
@jwt_refresh_token_required
async def serve_image(image_key: str):
try:
image_bytes, content_type = await s3_get_image(image_key)
except Exception:
return jsonify({"error": "Image not found"}), 404
return Response(
image_bytes,
content_type=content_type,
headers={"Cache-Control": "private, max-age=3600"},
)
@conversation_blueprint.post("/stream-query")
@jwt_refresh_token_required
async def stream_query():
@@ -137,16 +153,31 @@ async def stream_query():
data = await request.get_json()
query_text = data.get("query")
conversation_id = data.get("conversation_id")
image_key = data.get("image_key")
conversation = await get_conversation_by_id(conversation_id)
await conversation.fetch_related("messages")
await add_message_to_conversation(
conversation=conversation,
message=query_text,
message=query_text or "",
speaker="user",
user=user,
image_key=image_key,
)
messages_payload = _build_messages_payload(conversation, query_text)
# If an image was uploaded, analyze it with the vision model
image_description = None
if image_key:
try:
image_bytes, _ = await s3_get_image(image_key)
image_description = await analyze_user_image(image_bytes)
logging.info(f"Image analysis complete for {image_key}")
except Exception as e:
logging.error(f"Failed to analyze image: {e}")
image_description = "[Image could not be analyzed]"
messages_payload = _build_messages_payload(
conversation, query_text or "", image_description
)
payload = {"messages": messages_payload}
async def event_generator():
@@ -215,6 +246,7 @@ async def get_conversation(conversation_id: str):
"text": msg.text,
"speaker": msg.speaker.value,
"created_at": msg.created_at.isoformat(),
"image_key": msg.image_key,
}
)
name = conversation.name

View File

@@ -16,12 +16,14 @@ async def add_message_to_conversation(
message: str,
speaker: str,
user: blueprints.users.models.User,
image_key: str | None = None,
) -> ConversationMessage:
print(conversation, message, speaker)
message = await ConversationMessage.create(
text=message,
speaker=speaker,
conversation=conversation,
image_key=image_key,
)
return message

View File

@@ -41,6 +41,7 @@ class ConversationMessage(Model):
)
created_at = fields.DatetimeField(auto_now_add=True)
speaker = fields.CharEnumField(enum_type=Speaker, max_length=10)
image_key = fields.CharField(max_length=512, null=True, default=None)
class Meta:
table = "conversation_messages"

View File

@@ -0,0 +1,57 @@
SIMBA_SYSTEM_PROMPT = """You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
SIMBA FACTS (as of January 2026):
- Name: Simba
- Species: Feline (Domestic Short Hair / American Short Hair)
- Sex: Male, Neutered
- Date of Birth: August 8, 2016 (approximately 9 years 5 months old)
- Color: Orange
- Current Weight: 16 lbs (as of 1/8/2026)
- Owner: Ryan Chen
- Location: Long Island City, NY
- Veterinarian: Court Square Animal Hospital
Medical Conditions:
- Hypertrophic Cardiomyopathy (HCM): Diagnosed 12/11/2025. Concentric left ventricular hypertrophy with no left atrial dilation. Grade II-III/VI systolic heart murmur. No cardiac medications currently needed. Must avoid Domitor, acepromazine, and ketamine during anesthesia.
- Dental Issues: Prior extraction of teeth 307 and 407 due to resorption. Tooth 107 extracted on 1/8/2026. Early resorption lesions present on teeth 207, 309, and 409.
Recent Medical Events:
- 1/8/2026: Dental cleaning and tooth 107 extraction. Prescribed Onsior for 3 days. Oravet sealant applied.
- 12/11/2025: Echocardiogram confirming HCM diagnosis. Pre-op bloodwork was normal.
- 12/1/2025: Visited for decreased appetite/nausea. Received subcutaneous fluids and Cerenia.
Diet & Lifestyle:
- Diet: Hill's I/D wet and dry food
- Supplements: Plaque Off
- Indoor only cat, only pet in the household
Upcoming Appointments:
- Rabies Vaccine: Due 2/19/2026
- Routine Examination: Due 6/1/2026
- FVRCP-3yr Vaccine: Due 10/2/2026
IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.
BUDGET & FINANCE (YNAB Integration):
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
- Use ynab_budget_summary for overall budget health and status questions
- Use ynab_search_transactions to find specific purchases or spending at particular stores
- Use ynab_category_spending to analyze spending by category for a month
- Use ynab_insights to provide spending trends, patterns, and recommendations
Always use these tools when asked about budgets, spending, transactions, or financial health.
NOTES & RESEARCH (Obsidian Integration):
You have access to Ryan's Obsidian vault through the Obsidian integration. When users ask about research, personal notes, or information that might be stored in markdown files, use the appropriate Obsidian tools:
- Use obsidian_search_notes to search through your vault for relevant information
- Use obsidian_read_note to read the full content of a specific note by path
- Use obsidian_create_note to save new findings, ideas, or research to your vault
- Use obsidian_create_task to create task notes with due dates
Always use these tools when users ask about notes, research, ideas, tasks, or when you want to save information for future reference.
DAILY JOURNAL (Task Tracking):
You have access to Ryan's daily journal notes. Each note lives at journal/YYYY/YYYY-MM-DD.md and has two sections: tasks and log.
- Use journal_get_today to read today's full daily note (tasks + log)
- Use journal_get_tasks to list tasks (done/pending) for today or a specific date
- Use journal_add_task to add a new task to today's (or a given date's) note
- Use journal_complete_task to check off a task as done
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete."""

View File

@@ -0,0 +1,217 @@
import os
import hmac
import hashlib
import logging
import functools
import time
from collections import defaultdict
import httpx
from quart import Blueprint, request
from blueprints.users.models import User
from blueprints.conversation.logic import (
get_conversation_for_user,
add_message_to_conversation,
get_conversation_transcript,
)
from blueprints.conversation.agents import main_agent
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
from .helpers import generate_email_token, get_user_email_address # noqa: F401
email_blueprint = Blueprint("email_api", __name__, url_prefix="/api/email")
logger = logging.getLogger(__name__)
# Rate limiting: per-sender message timestamps
_rate_limit_store: dict[str, list[float]] = defaultdict(list)
RATE_LIMIT_MAX = int(os.getenv("EMAIL_RATE_LIMIT_MAX", "5"))
RATE_LIMIT_WINDOW = int(os.getenv("EMAIL_RATE_LIMIT_WINDOW", "300"))
MAX_MESSAGE_LENGTH = 2000
# --- Mailgun signature validation ---
def validate_mailgun_signature(f):
"""Decorator to validate Mailgun webhook signatures."""
@functools.wraps(f)
async def decorated_function(*args, **kwargs):
if os.getenv("MAILGUN_SIGNATURE_VALIDATION", "true").lower() == "false":
return await f(*args, **kwargs)
signing_key = os.getenv("MAILGUN_WEBHOOK_SIGNING_KEY")
if not signing_key:
logger.error("MAILGUN_WEBHOOK_SIGNING_KEY not set — rejecting request")
return "", 406
form_data = await request.form
timestamp = form_data.get("timestamp", "")
token = form_data.get("token", "")
signature = form_data.get("signature", "")
if not timestamp or not token or not signature:
logger.warning("Missing Mailgun signature fields")
return "", 406
expected = hmac.new(
signing_key.encode(),
f"{timestamp}{token}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
logger.warning("Invalid Mailgun signature")
return "", 406
return await f(*args, **kwargs)
return decorated_function
# --- Rate limiting ---
def _check_rate_limit(sender: str) -> bool:
"""Check if a sender has exceeded the rate limit.
Returns True if the request is allowed, False if rate-limited.
"""
now = time.monotonic()
cutoff = now - RATE_LIMIT_WINDOW
timestamps = _rate_limit_store[sender]
_rate_limit_store[sender] = [t for t in timestamps if t > cutoff]
if len(_rate_limit_store[sender]) >= RATE_LIMIT_MAX:
return False
_rate_limit_store[sender].append(now)
return True
# --- Send reply via Mailgun API ---
async def send_email_reply(to: str, subject: str, body: str, in_reply_to: str | None = None):
"""Send a reply email via the Mailgun API."""
api_key = os.getenv("MAILGUN_API_KEY")
domain = os.getenv("MAILGUN_DOMAIN")
if not api_key or not domain:
logger.error("MAILGUN_API_KEY or MAILGUN_DOMAIN not configured")
return
data = {
"from": f"Simba <simba@{domain}>",
"to": to,
"subject": f"Re: {subject}" if not subject.startswith("Re:") else subject,
"text": body,
}
if in_reply_to:
data["h:In-Reply-To"] = in_reply_to
async with httpx.AsyncClient() as client:
resp = await client.post(
f"https://api.mailgun.net/v3/{domain}/messages",
auth=("api", api_key),
data=data,
)
if resp.status_code != 200:
logger.error(f"Mailgun send failed ({resp.status_code}): {resp.text}")
else:
logger.info(f"Sent email reply to {to}")
# --- Webhook route ---
@email_blueprint.route("/webhook", methods=["POST"])
@validate_mailgun_signature
async def webhook():
"""Handle inbound emails forwarded by Mailgun."""
form_data = await request.form
sender = form_data.get("sender", "")
recipient = form_data.get("recipient", "")
body = form_data.get("stripped-text", "")
subject = form_data.get("subject", "(no subject)")
message_id = form_data.get("Message-Id", "")
# Extract token from recipient: ask+<token>@domain
local_part = recipient.split("@")[0] if "@" in recipient else ""
if "+" not in local_part:
logger.info(f"Ignoring email to {recipient} — no token in address")
return "", 200
token = local_part.split("+", 1)[1]
# Lookup user by token
user = await User.filter(email_hmac_token=token, email_enabled=True).first()
if not user:
logger.info(f"No user found for email token {token}")
return "", 200
# Rate limit
if not _check_rate_limit(sender):
logger.warning(f"Rate limit exceeded for email sender {sender}")
return "", 200
# Clean up body
body = (body or "").strip()
if not body:
logger.info(f"Ignoring empty email from {sender}")
return "", 200
if len(body) > MAX_MESSAGE_LENGTH:
body = body[:MAX_MESSAGE_LENGTH]
logger.info(f"Truncated long email from {sender} to {MAX_MESSAGE_LENGTH} chars")
logger.info(f"Processing email from {sender} for user {user.username}: {body[:100]}")
# Get or create conversation
try:
conversation = await get_conversation_for_user(user=user)
await conversation.fetch_related("messages")
except Exception as e:
logger.error(f"Failed to get conversation for user {user.username}: {e}")
return "", 200
# Add user message
await add_message_to_conversation(
conversation=conversation,
message=body,
speaker="user",
user=user,
)
# Build messages payload
try:
messages = await conversation.messages.all()
recent_messages = list(messages)[-10:]
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
for msg in recent_messages[:-1]:
role = "user" if msg.speaker == "user" else "assistant"
messages_payload.append({"role": role, "content": msg.text})
messages_payload.append({"role": "user", "content": body})
logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages")
response = await main_agent.ainvoke({"messages": messages_payload})
response_text = response.get("messages", [])[-1].content
except Exception as e:
logger.error(f"Error invoking agent for email: {e}")
response_text = "Sorry, I'm having trouble thinking right now."
# Save response
await add_message_to_conversation(
conversation=conversation,
message=response_text,
speaker="simba",
user=user,
)
# Send reply email
await send_email_reply(
to=sender,
subject=subject,
body=response_text,
in_reply_to=message_id,
)
return "", 200

View File

@@ -0,0 +1,14 @@
import hmac
import hashlib
def generate_email_token(user_id: str, secret: str) -> str:
"""Generate a 16-char hex HMAC token for a user's email address."""
return hmac.new(
secret.encode(), str(user_id).encode(), hashlib.sha256
).hexdigest()[:16]
def get_user_email_address(token: str, domain: str) -> str:
"""Return the routable email address for a given token."""
return f"ask+{token}@{domain}"

View File

@@ -7,7 +7,9 @@ from quart_jwt_extended import (
)
from .models import User
from .oidc_service import OIDCUserService
from .decorators import admin_required
from config.oidc_config import oidc_config
import os
import secrets
import httpx
from urllib.parse import urlencode
@@ -131,6 +133,21 @@ async def oidc_callback():
except Exception as e:
return jsonify({"error": f"ID token verification failed: {str(e)}"}), 400
# Fetch userinfo to get groups (older Authelia versions only include groups there)
userinfo_endpoint = discovery.get("userinfo_endpoint")
if userinfo_endpoint:
access_token_str = tokens.get("access_token")
if access_token_str:
async with httpx.AsyncClient() as client:
userinfo_response = await client.get(
userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token_str}"},
)
if userinfo_response.status_code == 200:
userinfo = userinfo_response.json()
if "groups" in userinfo and "groups" not in claims:
claims["groups"] = userinfo["groups"]
# Get or create user from OIDC claims
user = await OIDCUserService.get_or_create_user_from_oidc(claims)
@@ -186,3 +203,122 @@ async def login():
refresh_token=refresh_token,
user={"id": str(user.id), "username": user.username},
)
@user_blueprint.route("/me", methods=["GET"])
@jwt_refresh_token_required
async def me():
user_id = get_jwt_identity()
user = await User.get_or_none(id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify({
"id": str(user.id),
"username": user.username,
"email": user.email,
"is_admin": user.is_admin(),
})
@user_blueprint.route("/admin/users", methods=["GET"])
@admin_required
async def list_users():
from blueprints.email.helpers import get_user_email_address
users = await User.all().order_by("username")
mailgun_domain = os.getenv("MAILGUN_DOMAIN", "")
return jsonify([
{
"id": str(u.id),
"username": u.username,
"email": u.email,
"whatsapp_number": u.whatsapp_number,
"auth_provider": u.auth_provider,
"email_enabled": u.email_enabled,
"email_address": get_user_email_address(u.email_hmac_token, mailgun_domain) if u.email_hmac_token and u.email_enabled else None,
}
for u in users
])
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["PUT"])
@admin_required
async def set_whatsapp(user_id):
data = await request.get_json()
number = (data or {}).get("whatsapp_number", "").strip()
if not number:
return jsonify({"error": "whatsapp_number is required"}), 400
user = await User.get_or_none(id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
conflict = await User.filter(whatsapp_number=number).exclude(id=user_id).first()
if conflict:
return jsonify({"error": "That WhatsApp number is already linked to another account"}), 409
user.whatsapp_number = number
await user.save()
return jsonify({
"id": str(user.id),
"username": user.username,
"email": user.email,
"whatsapp_number": user.whatsapp_number,
"auth_provider": user.auth_provider,
})
@user_blueprint.route("/admin/users/<user_id>/whatsapp", methods=["DELETE"])
@admin_required
async def unlink_whatsapp(user_id):
user = await User.get_or_none(id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
user.whatsapp_number = None
await user.save()
return jsonify({"ok": True})
@user_blueprint.route("/admin/users/<user_id>/email", methods=["PUT"])
@admin_required
async def toggle_email(user_id):
"""Enable email channel for a user, generating an HMAC token."""
from blueprints.email.helpers import generate_email_token, get_user_email_address
user = await User.get_or_none(id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
email_secret = os.getenv("EMAIL_HMAC_SECRET")
if not email_secret:
return jsonify({"error": "EMAIL_HMAC_SECRET not configured"}), 500
mailgun_domain = os.getenv("MAILGUN_DOMAIN", "")
if not user.email_hmac_token:
user.email_hmac_token = generate_email_token(user.id, email_secret)
user.email_enabled = True
await user.save()
return jsonify({
"id": str(user.id),
"username": user.username,
"email": user.email,
"whatsapp_number": user.whatsapp_number,
"auth_provider": user.auth_provider,
"email_enabled": user.email_enabled,
"email_address": get_user_email_address(user.email_hmac_token, mailgun_domain),
})
@user_blueprint.route("/admin/users/<user_id>/email", methods=["DELETE"])
@admin_required
async def disable_email(user_id):
"""Disable email channel and clear the token."""
user = await User.get_or_none(id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
user.email_enabled = False
user.email_hmac_token = None
await user.save()
return jsonify({"ok": True})

View File

@@ -12,6 +12,10 @@ class User(Model):
email = fields.CharField(max_length=100, unique=True)
whatsapp_number = fields.CharField(max_length=30, unique=True, null=True, index=True)
# Email channel fields
email_enabled = fields.BooleanField(default=False)
email_hmac_token = fields.CharField(max_length=16, unique=True, null=True, index=True)
# OIDC fields
oidc_subject = fields.CharField(
max_length=255, unique=True, null=True, index=True

View File

@@ -15,6 +15,7 @@ from blueprints.conversation.logic import (
get_conversation_transcript,
)
from blueprints.conversation.agents import main_agent
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
whatsapp_blueprint = Blueprint("whatsapp_api", __name__, url_prefix="/api/whatsapp")
@@ -170,55 +171,12 @@ async def webhook():
# Build messages payload for LangChain agent with system prompt and conversation history
try:
# System prompt with Simba's facts and medical information
system_prompt = """You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
SIMBA FACTS (as of January 2026):
- Name: Simba
- Species: Feline (Domestic Short Hair / American Short Hair)
- Sex: Male, Neutered
- Date of Birth: August 8, 2016 (approximately 9 years 5 months old)
- Color: Orange
- Current Weight: 16 lbs (as of 1/8/2026)
- Owner: Ryan Chen
- Location: Long Island City, NY
- Veterinarian: Court Square Animal Hospital
Medical Conditions:
- Hypertrophic Cardiomyopathy (HCM): Diagnosed 12/11/2025. Concentric left ventricular hypertrophy with no left atrial dilation. Grade II-III/VI systolic heart murmur. No cardiac medications currently needed. Must avoid Domitor, acepromazine, and ketamine during anesthesia.
- Dental Issues: Prior extraction of teeth 307 and 407 due to resorption. Tooth 107 extracted on 1/8/2026. Early resorption lesions present on teeth 207, 309, and 409.
Recent Medical Events:
- 1/8/2026: Dental cleaning and tooth 107 extraction. Prescribed Onsior for 3 days. Oravet sealant applied.
- 12/11/2025: Echocardiogram confirming HCM diagnosis. Pre-op bloodwork was normal.
- 12/1/2025: Visited for decreased appetite/nausea. Received subcutaneous fluids and Cerenia.
Diet & Lifestyle:
- Diet: Hill's I/D wet and dry food
- Supplements: Plaque Off
- Indoor only cat, only pet in the household
Upcoming Appointments:
- Rabies Vaccine: Due 2/19/2026
- Routine Examination: Due 6/1/2026
- FVRCP-3yr Vaccine: Due 10/2/2026
IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.
BUDGET & FINANCE (YNAB Integration):
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
- Use ynab_budget_summary for overall budget health and status questions
- Use ynab_search_transactions to find specific purchases or spending at particular stores
- Use ynab_category_spending to analyze spending by category for a month
- Use ynab_insights to provide spending trends, patterns, and recommendations
Always use these tools when asked about budgets, spending, transactions, or financial health."""
# Get last 10 messages for conversation history
messages = await conversation.messages.all()
recent_messages = list(messages)[-10:]
# Build messages payload
messages_payload = [{"role": "system", "content": system_prompt}]
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
# Add recent conversation history (exclude the message we just added)
for msg in recent_messages[:-1]:

View File

@@ -1,15 +1,14 @@
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Database configuration with environment variable support
# Use DATABASE_PATH for relative paths or DATABASE_URL for full connection strings
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite://{DATABASE_PATH}")
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgres://raggr:raggr_dev_password@localhost:5432/raggr"
)
TORTOISE_ORM = {
TORTOISE_CONFIG = {
"connections": {"default": DATABASE_URL},
"apps": {
"models": {

View File

@@ -55,6 +55,12 @@ services:
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-asksimba-images}
- S3_REGION=${S3_REGION:-garage}
- OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11434}
depends_on:
postgres:
condition: service_healthy

View File

@@ -0,0 +1,46 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "users" ADD "email_enabled" BOOL NOT NULL DEFAULT FALSE;
ALTER TABLE "users" ADD "email_hmac_token" VARCHAR(16) UNIQUE;
CREATE INDEX "idx_users_email_h_a1b2c3" ON "users" ("email_hmac_token");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_users_email_h_a1b2c3";
ALTER TABLE "users" DROP COLUMN "email_hmac_token";
ALTER TABLE "users" DROP COLUMN "email_enabled";"""
MODELS_STATE = (
"eJztmm1v4jgQx78Kyquu1KtaKN1VdTopUHrLbYEThX3qVZFJXMg1sbOJsxRV/e5nm4Q4jg"
"OEAoU93rRl7CH2z2PP35M+ay62oBOc1DH6Cf0AEBsj7bL0rCHgQvqHsv24pAHPS1qZgYCB"
"wx1MoSdvAYOA+MAktPEBOAGkJgsGpm970cNQ6DjMiE3a0UbDxBQi+0cIDYKHkIygTxvu7q"
"nZRhZ8gkH80Xs0HmzoWKlx2xZ7NrcbZOJxW7/fvLrmPdnjBoaJndBFSW9vQkYYzbqHoW2d"
"MB/WNoQI+oBAS5gGG2U07dg0HTE1ED+Es6FaicGCDyB0GAzt94cQmYxBiT+J/Tj/QyuAh6"
"JmaG1EGIvnl+mskjlzq8YeVf+od48qF+/4LHFAhj5v5ES0F+4ICJi6cq4JSP47g7I+Ar4a"
"ZdxfgkkHugrG2JBwTGIoBhkDWo2a5oInw4FoSEb0Y7lanYPxs97lJGkvjhLTuJ5GfTtqKk"
"/bGNIEoelDNmUDkCzIK9pCbBeqYaY9JaRW5HoS/7GjgOkcrA5yJtEmmMO312w1bnt66282"
"EzcIfjgckd5rsJYyt04k69GFtBSzLyl9afY+ltjH0vdOuyHH/qxf77vGxgRCgg2ExwawhP"
"0aW2MwqYUNPWvFhU17Hhb2TRc2GrywrgH0jWIZRHB5RRqJxrbFRVw9abDU+/CozBkMRhbe"
"NfahPUSf4IQjbNJxAGSqkkUkOvrR1+wqtMSajMIH45kaEYOCzo7OCZJp9tRv6/pVQ+MMB8"
"B8HAPfMnJgujAIwBAGWaC1yPP6Uxc6M2mmZikKuNb0G3fzVMljy1nhMhYYpehlm9yyK1sA"
"ovO2omezJ82hs0AFCxCXE8OGuJAHUbzXopjAJ0XK71GrGmXcf19E8bxU3vjaS2XxWPoetf"
"Sv71KZ/KbT/jPuLkjl+k2ndlDIv6KQyirkwIPgUSUG2AWygUI3IwVSqyu4v/HW0fq3je5l"
"iWX0f9Bts1XTL0uB7Q6AttwSp26ZZ6dLXDLPTnPvmKxJ2kBioil2zCtc13nm76mENaWC1y"
"ulrFw/21mKCzWtIlyKattNKjl+Z1BIt/guka/V2NY+aLP912ZsHYsWLUWffdFoWyhceiAI"
"xthXRGbNRsCfqGGKXhLMwYRM7z+7eqVXwasxvSrKLYqs1mzr3W9qyRv3F+O29q3X0CW60A"
"W2UyRKZw7rCdHFO36dAXp2upzomad6MrJnPAIkoEe6QZXkIE9mqmEqXFfCKofqdqlWloFa"
"yWdaySDlQWZAxKan2vgYOxCgOQEq+srbnzpv6jAtmqoL7P9O5ya1/2tN+Urbb9UaNHg5Zt"
"rJnkqhZrunhDtygUk1wiNUKMsFu1/y3cOIPbtY5hiQr6zCKXAhRyy2LdMIwsG/0FSUD/KB"
"yn57CHMjWZ9e6EeG5+OftlXsSM04bk9KaQ42gfMKLZrmWl3mWK3mH6vVzLHqWMAzhj4OPU"
"Uh/6/bTluNVHKTgPYRneWdZZvkuOTYAbnfGN67+83ofDbz+dVEuXAoCSv2BYdq4v+kmnh4"
"3/5LLOzsdV6mKrToXWjmn8vW80J0l2+k230RqkPfNkeaooAWtRzPK6GBpM/O1NCaKOednL"
"KExjBLwRCt/JvepPnr6N/KZ+fvzz9ULs4/0C58JDPL+zmHQXwNyS+ZsY2grHPnaz3B5VAw"
"S6Qz3RpFBPO0+34C3EhBhz6RQKRI7/kSWXB5K3m8sdLj2uRxgWy7/vTy8h9Mf/k3"
)

View File

@@ -0,0 +1,43 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "conversation_messages" ADD "image_key" VARCHAR(512);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "conversation_messages" DROP COLUMN "image_key";"""
MODELS_STATE = (
"eJztmmtv4jgUhv8KyqeO1K0KvcyoWq0UWrrDToFVC3PrVpFJXPCS2JnEGYqq/ve1TUIcx6"
"GkBQqzfGnLsQ+xH1/Oe076aHjEgW54cE7wTxiEgCKCjbPKo4GBB9kf2vb9igF8P23lBgr6"
"rnCwpZ6iBfRDGgCbssZ74IaQmRwY2gHy44fhyHW5kdisI8KD1BRh9COCFiUDSIcwYA23d8"
"yMsAMfYJh89EfWPYKukxk3cvizhd2iE1/Yer3mxaXoyR/Xt2ziRh5Oe/sTOiR41j2KkHPA"
"fXjbAGIYAAodaRp8lPG0E9N0xMxAgwjOhuqkBgfeg8jlMIzf7yNscwYV8ST+4/gPowQehp"
"qjRZhyFo9P01mlcxZWgz/q/KN5vXd0+k7MkoR0EIhGQcR4Eo6Agqmr4JqCFL9zKM+HINCj"
"TPorMNlAX4IxMaQc0z2UgEwAvYya4YEHy4V4QIfsY+3kZA7Gz+a1IMl6CZSE7evprm/HTb"
"VpG0eaIrQDyKdsAZoHecFaKPKgHmbWU0HqxK4HyR8bCpjNwelgdxIfgjl8u81W46Zrtv7m"
"M/HC8IcrEJndBm+pCetEse6dKksx+5LKl2b3Y4V/rHzvtBvq3p/16343+JhARImFydgCjn"
"ReE2sCJrOwke+8cGGznruFfdOFjQcvrWsIA6tcBJFcXhFG4rGtcRFfHjR46L0faWMGh5GH"
"d0kCiAb4E5wIhE02DoBtXbCIRUcv/ppNhZZa01EEYDxTI/KmYLNjc4J0Gj3Nm3PzomEIhn"
"1gj8YgcKwCmB4MQzCAYR5oPfa8/HQN3Zk007OUBVxr+o2beasUsRWsSI1IjDL08k1ezVMt"
"ALN5O/Gz+ZPm0HlGBUsQFxPDlryQO1G81aKYwgdNyO8yqx5l0n9bRPG8UN742s1E8UT67r"
"XMr+8ykfyq0/4z6S5J5fOrTn2nkH9FIZVXyKEPwUgnBngC2cCRl5MCmdWV3N/46Bi9m8b1"
"WYVH9H/wTbNVN88qIfL6wFhsiTNZZvVwgSSzeliYY/Km7AFCHoss1ghOyqTqGacX8V2/9M"
"qCPKnWFiDJehWiFG3KZSQH7XIhU+O6zPi5pemArRQPX5kWqLXIjaX4bH6g2S5l84RVqmKR"
"f2lkcJKXFetefk3udO7261y+jmULwLLPtujdNRSBfRCGYxJodmYdYRBM9DBlLwVmf0Knue"
"TGxeg58Opc+8vSlSGrN9vm9Td9+pD0l/dt/Vu3YSp0oQeQW2aXzhyWs0WfP/HL3KDVw8UE"
"5DwFmZOQ4yGgIbvSLabK+0WSXQ9T47oUObleqkeLQD0qZnqUQyo2mQUxn57u4BPiQoDnbF"
"DZVz3+zHlVl2nZUF3i/Hc6V5nzX2+q5YFeq95gm1dgZp3QVAo1210t3KEHbKYRRlCjLJ85"
"/YrvFu7Y6uki14Ca/ku3wKm6YwlybCuM+v9CW1OKKQaq+m0hzJVEfRDRoeUH5Cdyyl2pOc"
"f1SSnDJTZwX6FFlRx9kWv1pPhaPcldq64DfGsQkMjXvBT566bT1iNV3BSgPcxmeesgm+5X"
"XBTSu5Xhvb1bjc7nM59fmVWLsIqw4l+wq8z+Tyqzu/9d+CUWdvZqNFcVeu69cu4f9Zbzcn"
"mTM9L1vlQ2YYDsoaEpoMUt+/NKaCDtszE1tCYueL+pLaFxzMpmiFf+TTNp8Wr/t1r1+P3x"
"h6PT4w+sixjJzPJ+zmWQpCHFJTN+ELR17mKtJ7nsCmapdGZHo4xgnnbfToArKeiwJ1KINe"
"G9WCJLLm8lj1dWelyaPC4RbZcfXp7+AzcBYwM="
)

View File

@@ -37,9 +37,10 @@ dependencies = [
"ynab>=1.3.0",
"ollama>=0.6.1",
"twilio>=9.10.2",
"aioboto3>=13.0.0",
]
[tool.aerich]
tortoise_orm = "app.TORTOISE_CONFIG"
tortoise_orm = "config.db.TORTOISE_CONFIG"
location = "./migrations"
src_folder = "./."

View File

@@ -12,11 +12,15 @@
},
"dependencies": {
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"marked": "^16.3.0",
"npm-watch": "^0.13.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"tailwind-merge": "^3.5.0",
"watch": "^1.0.2"
},
"devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,14 @@
{
"name": "Ask Simba",
"short_name": "Simba",
"description": "Chat with Simba - your AI cat companion",
"start_url": "/",
"display": "standalone",
"background_color": "#FAF8F2",
"theme_color": "#2A4D38",
"icons": [
{ "src": "/pwa-icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/pwa-icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/pwa-icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,46 @@
const CACHE = 'simba-v1';
self.addEventListener('install', (e) => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
const { request } = e;
const url = new URL(request.url);
// Network-only for API calls
if (url.pathname.startsWith('/api/')) return;
// Cache-first for fingerprinted static assets
if (url.pathname.startsWith('/static/')) {
e.respondWith(
caches.match(request).then(
(cached) =>
cached ||
fetch(request).then((res) => {
const clone = res.clone();
caches.open(CACHE).then((c) => c.put(request, clone));
return res;
})
)
);
return;
}
// Network-first for navigation (offline fallback to cache)
if (request.mode === 'navigate') {
e.respondWith(
fetch(request).catch(() => caches.match(request))
);
return;
}
});

View File

@@ -4,7 +4,16 @@ import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({
plugins: [pluginReact()],
html: {
title: 'Raggr',
title: 'Ask Simba',
favicon: './src/assets/favicon.svg',
tags: [
{ tag: 'link', attrs: { rel: 'manifest', href: '/manifest.json' } },
{ tag: 'meta', attrs: { name: 'theme-color', content: '#2A4D38' } },
{ tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' } },
{ tag: 'meta', attrs: { name: 'apple-mobile-web-app-capable', content: 'yes' } },
],
},
output: {
copy: [{ from: './public', to: '.' }],
},
});

View File

@@ -1,26 +1,44 @@
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap');
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap');
@theme {
--color-cream: #FBF7F0;
--color-cream-dark: #F3EDE2;
/* === Animal Crossing × Claude Palette === */
/* Backgrounds */
--color-cream: #FAF8F2;
--color-cream-dark: #F0EBDF;
--color-warm-white: #FFFDF9;
/* Forest / Nook Green system */
--color-forest: #2A4D38;
--color-forest-mid: #345E46;
--color-forest-light: #4D7A5E;
--color-leaf: #5E9E70;
--color-leaf-dark: #3D7A52;
--color-leaf-light: #B8DEC4;
--color-leaf-pale: #EBF7EE;
/* Amber / warm accents */
--color-amber-glow: #E8943A;
--color-amber-dark: #C97828;
--color-amber-soft: #F5C882;
--color-amber-pale: #FFF0D6;
--color-forest: #2D5A3D;
--color-forest-light: #3D763A;
--color-forest-pale: #E8F5E4;
--color-amber-pale: #FFF4E0;
/* Neutrals */
--color-charcoal: #2C2420;
--color-warm-gray: #8A7E74;
--color-sand: #D4C5B0;
--color-sand-light: #E8DED0;
--color-warm-gray: #7A7268;
--color-sand: #DECFB8;
--color-sand-light: #EDE3D4;
--color-blush: #F2D1B3;
--color-sidebar-bg: #2C2420;
--color-sidebar-hover: #3D352F;
--color-sidebar-active: #4A3F38;
/* Sidebar */
--color-sidebar-bg: #2A4D38;
--color-sidebar-hover: #345E46;
--color-sidebar-active: #3D6E52;
/* Fonts */
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif;
--font-body: 'Nunito', 'Nunito Sans', system-ui, sans-serif;
}
* {
@@ -36,97 +54,92 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-sand);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-warm-gray);
}
/* ── Scrollbar ─────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-sand); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-warm-gray); }
/* ── Markdown in answer bubbles ─────────────────────── */
.markdown-content p { margin: 0.5em 0; line-height: 1.7; }
.markdown-content p:first-child { margin-top: 0; }
.markdown-content p:last-child { margin-bottom: 0; }
/* Markdown content styling in answer bubbles */
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
font-family: var(--font-display);
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
margin: 1em 0 0.4em;
line-height: 1.3;
color: var(--color-charcoal);
}
.markdown-content h1 { font-size: 1.25rem; }
.markdown-content h2 { font-size: 1.1rem; }
.markdown-content h3 { font-size: 1rem; }
.markdown-content p {
margin: 0.5em 0;
line-height: 1.65;
}
.markdown-content h1 { font-size: 1.2rem; }
.markdown-content h2 { font-size: 1.05rem; }
.markdown-content h3 { font-size: 0.95rem; }
.markdown-content ul,
.markdown-content ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.markdown-content li {
margin: 0.25em 0;
line-height: 1.6;
}
.markdown-content ol { padding-left: 1.4em; margin: 0.5em 0; }
.markdown-content li { margin: 0.3em 0; line-height: 1.6; }
.markdown-content code {
background: rgba(0,0,0,0.06);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.88em;
font-family: 'SF Mono', 'Fira Code', monospace;
border-radius: 5px;
font-size: 0.85em;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
.markdown-content pre {
background: var(--color-charcoal);
color: #F3EDE2;
padding: 1em;
border-radius: 8px;
color: #F0EBDF;
padding: 1em 1.1em;
border-radius: 12px;
overflow-x: auto;
margin: 0.75em 0;
}
.markdown-content pre code {
background: none;
padding: 0;
color: inherit;
margin: 0.8em 0;
}
.markdown-content pre code { background: none; padding: 0; color: inherit; }
.markdown-content a {
color: var(--color-forest);
color: var(--color-leaf-dark);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown-content blockquote {
border-left: 3px solid var(--color-amber-glow);
border-left: 3px solid var(--color-amber-soft);
padding-left: 1em;
margin: 0.75em 0;
color: var(--color-warm-gray);
font-style: italic;
}
/* Loading skeleton animation */
.markdown-content strong { font-weight: 700; }
.markdown-content em { font-style: italic; }
/* ── Animations ─────────────────────────────────────── */
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message-enter {
animation: fadeSlideUp 0.3s ease-out forwards;
}
@keyframes catPulse {
0%, 80%, 100% { opacity: 0.25; transform: scale(0.75); }
40% { opacity: 1; transform: scale(1); }
}
.loading-dot { animation: catPulse 1.4s ease-in-out infinite; }
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-shimmer {
background: linear-gradient(
90deg,
background: linear-gradient(90deg,
var(--color-sand-light) 25%,
var(--color-cream) 50%,
var(--color-sand-light) 75%
@@ -135,36 +148,26 @@ body {
animation: shimmer 1.8s ease-in-out infinite;
}
/* Fade-in animation for messages */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(12px);
/* ── Toggle switch ──────────────────────────────────── */
.toggle-track {
width: 36px;
height: 20px;
border-radius: 99px;
background: var(--color-sand);
position: relative;
transition: background 0.2s;
cursor: pointer;
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-enter {
animation: fadeSlideUp 0.35s ease-out forwards;
}
/* Subtle pulse for loading dots */
@keyframes catPulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.loading-dot {
animation: catPulse 1.4s ease-in-out infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
/* Textarea focus glow */
textarea:focus {
outline: none;
box-shadow: 0 0 0 2px var(--color-amber-soft);
.toggle-track.checked { background: var(--color-leaf); }
.toggle-thumb {
width: 14px;
height: 14px;
background: white;
border-radius: 99px;
position: absolute;
top: 3px;
left: 3px;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.toggle-track.checked .toggle-thumb { transform: translateX(16px); }

View File

@@ -13,6 +13,7 @@ interface Message {
text: string;
speaker: "user" | "simba";
created_at: string;
image_key?: string | null;
}
interface Conversation {
@@ -121,17 +122,52 @@ class ConversationService {
return await response.json();
}
async uploadImage(
file: File,
conversationId: string,
): Promise<{ image_key: string; image_url: string }> {
const formData = new FormData();
formData.append("file", file);
formData.append("conversation_id", conversationId);
const response = await userService.fetchWithRefreshToken(
`${this.conversationBaseUrl}/upload-image`,
{
method: "POST",
body: formData,
},
{ skipContentType: true },
);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to upload image");
}
return await response.json();
}
getImageUrl(imageKey: string): string {
return `/api/conversation/image/${imageKey}`;
}
async streamQuery(
query: string,
conversation_id: string,
onEvent: SSEEventCallback,
signal?: AbortSignal,
imageKey?: string,
): Promise<void> {
const body: Record<string, string> = { query, conversation_id };
if (imageKey) {
body.image_key = imageKey;
}
const response = await userService.fetchWithRefreshToken(
`${this.conversationBaseUrl}/stream-query`,
{
method: "POST",
body: JSON.stringify({ query, conversation_id }),
body: JSON.stringify(body),
signal,
},
);

View File

@@ -106,14 +106,15 @@ class UserService {
async fetchWithRefreshToken(
url: string,
options: RequestInit = {},
{ skipContentType = false }: { skipContentType?: boolean } = {},
): Promise<Response> {
const refreshToken = localStorage.getItem("refresh_token");
// Add authorization header
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
...(refreshToken && { Authorization: `Bearer ${refreshToken}` }),
const headers: Record<string, string> = {
...(skipContentType ? {} : { "Content-Type": "application/json" }),
...((options.headers as Record<string, string>) || {}),
...(refreshToken ? { Authorization: `Bearer ${refreshToken}` } : {}),
};
let response = await fetch(url, { ...options, headers });
@@ -134,6 +135,67 @@ class UserService {
return response;
}
async getMe(): Promise<{ id: string; username: string; email: string; is_admin: boolean }> {
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/me`);
if (!response.ok) throw new Error("Failed to fetch user profile");
return response.json();
}
async adminListUsers(): Promise<AdminUserRecord[]> {
const response = await this.fetchWithRefreshToken(`${this.baseUrl}/admin/users`);
if (!response.ok) throw new Error("Failed to list users");
return response.json();
}
async adminSetWhatsapp(userId: string, number: string): Promise<AdminUserRecord> {
const response = await this.fetchWithRefreshToken(
`${this.baseUrl}/admin/users/${userId}/whatsapp`,
{ method: "PUT", body: JSON.stringify({ whatsapp_number: number }) },
);
if (response.status === 409) {
const data = await response.json();
throw new Error(data.error ?? "WhatsApp number already in use");
}
if (!response.ok) throw new Error("Failed to set WhatsApp number");
return response.json();
}
async adminUnlinkWhatsapp(userId: string): Promise<void> {
const response = await this.fetchWithRefreshToken(
`${this.baseUrl}/admin/users/${userId}/whatsapp`,
{ method: "DELETE" },
);
if (!response.ok) throw new Error("Failed to unlink WhatsApp number");
}
async adminToggleEmail(userId: string): Promise<AdminUserRecord> {
const response = await this.fetchWithRefreshToken(
`${this.baseUrl}/admin/users/${userId}/email`,
{ method: "PUT" },
);
if (!response.ok) throw new Error("Failed to enable email");
return response.json();
}
async adminDisableEmail(userId: string): Promise<void> {
const response = await this.fetchWithRefreshToken(
`${this.baseUrl}/admin/users/${userId}/email`,
{ method: "DELETE" },
);
if (!response.ok) throw new Error("Failed to disable email");
}
}
export interface AdminUserRecord {
id: string;
username: string;
email: string;
whatsapp_number: string | null;
auth_provider: string;
email_enabled: boolean;
email_address: string | null;
}
export { UserService };
export const userService = new UserService();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,312 @@
import { useEffect, useState } from "react";
import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
import { userService, type AdminUserRecord } from "../api/userService";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./ui/table";
type Props = {
onClose: () => void;
};
export const AdminPanel = ({ onClose }: Props) => {
const [users, setUsers] = useState<AdminUserRecord[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [rowError, setRowError] = useState<Record<string, string>>({});
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
useEffect(() => {
userService
.adminListUsers()
.then(setUsers)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const startEdit = (user: AdminUserRecord) => {
setEditingId(user.id);
setEditValue(user.whatsapp_number ?? "");
setRowError((p) => ({ ...p, [user.id]: "" }));
setRowSuccess((p) => ({ ...p, [user.id]: "" }));
};
const cancelEdit = () => {
setEditingId(null);
setEditValue("");
};
const saveWhatsapp = async (userId: string) => {
setRowError((p) => ({ ...p, [userId]: "" }));
try {
const updated = await userService.adminSetWhatsapp(userId, editValue);
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
setRowSuccess((p) => ({ ...p, [userId]: "Saved ✓" }));
setEditingId(null);
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) {
setRowError((p) => ({
...p,
[userId]: err instanceof Error ? err.message : "Failed to save",
}));
}
};
const unlinkWhatsapp = async (userId: string) => {
setRowError((p) => ({ ...p, [userId]: "" }));
try {
await userService.adminUnlinkWhatsapp(userId);
setUsers((p) =>
p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
);
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) {
setRowError((p) => ({
...p,
[userId]: err instanceof Error ? err.message : "Failed to unlink",
}));
}
};
const toggleEmail = async (userId: string) => {
setRowError((p) => ({ ...p, [userId]: "" }));
try {
const updated = await userService.adminToggleEmail(userId);
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled ✓" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) {
setRowError((p) => ({
...p,
[userId]: err instanceof Error ? err.message : "Failed to enable email",
}));
}
};
const disableEmail = async (userId: string) => {
setRowError((p) => ({ ...p, [userId]: "" }));
try {
await userService.adminDisableEmail(userId);
setUsers((p) =>
p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)),
);
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) {
setRowError((p) => ({
...p,
[userId]: err instanceof Error ? err.message : "Failed to disable email",
}));
}
};
const copyToClipboard = (text: string, userId: string) => {
navigator.clipboard.writeText(text);
setRowSuccess((p) => ({ ...p, [userId]: "Copied ✓" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-charcoal/40 backdrop-blur-sm"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
className={cn(
"bg-warm-white rounded-3xl shadow-2xl shadow-charcoal/20",
"w-full max-w-3xl mx-4 max-h-[82vh] flex flex-col",
"border border-sand-light/60",
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
<Phone size={14} className="text-leaf-dark" />
</div>
<h2 className="text-sm font-semibold text-charcoal">
Admin · User Integrations
</h2>
</div>
<button
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-warm-gray hover:text-charcoal hover:bg-cream-dark transition-colors cursor-pointer"
>
<X size={15} />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 rounded-b-3xl">
{loading ? (
<div className="px-6 py-12 text-center text-warm-gray text-sm">
<div className="flex justify-center gap-1.5 mb-3">
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
</div>
Loading users
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead>WhatsApp</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-28">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium text-charcoal">
{user.username}
</TableCell>
<TableCell className="text-warm-gray">{user.email}</TableCell>
<TableCell>
{editingId === user.id ? (
<div className="flex flex-col gap-1">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder="whatsapp:+15551234567"
className="w-52"
autoFocus
onKeyDown={(e) =>
e.key === "Enter" && saveWhatsapp(user.id)
}
/>
{rowError[user.id] && (
<span className="text-xs text-red-500">
{rowError[user.id]}
</span>
)}
</div>
) : (
<div className="flex flex-col gap-0.5">
<span
className={cn(
"text-sm",
user.whatsapp_number
? "text-charcoal"
: "text-warm-gray/40 italic",
)}
>
{user.whatsapp_number ?? "—"}
</span>
{rowSuccess[user.id] && (
<span className="text-xs text-leaf-dark">
{rowSuccess[user.id]}
</span>
)}
{rowError[user.id] && (
<span className="text-xs text-red-500">
{rowError[user.id]}
</span>
)}
</div>
)}
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
{user.email_enabled && user.email_address ? (
<div className="flex items-center gap-1.5">
<span className="text-sm text-charcoal truncate max-w-[180px]" title={user.email_address}>
{user.email_address}
</span>
<button
onClick={() => copyToClipboard(user.email_address!, user.id)}
className="text-warm-gray hover:text-charcoal transition-colors cursor-pointer"
title="Copy address"
>
<Copy size={11} />
</button>
</div>
) : (
<span className="text-sm text-warm-gray/40 italic"></span>
)}
</div>
</TableCell>
<TableCell>
{editingId === user.id ? (
<div className="flex gap-1.5">
<Button
size="sm"
variant="default"
onClick={() => saveWhatsapp(user.id)}
>
<Check size={12} />
Save
</Button>
<Button
size="sm"
variant="ghost-dark"
onClick={cancelEdit}
>
Cancel
</Button>
</div>
) : (
<div className="flex gap-1.5">
<Button
size="sm"
variant="ghost-dark"
onClick={() => startEdit(user)}
>
<Pencil size={11} />
Edit
</Button>
{user.whatsapp_number && (
<Button
size="sm"
variant="destructive"
onClick={() => unlinkWhatsapp(user.id)}
>
<PhoneOff size={11} />
Unlink
</Button>
)}
{user.email_enabled ? (
<Button
size="sm"
variant="destructive"
onClick={() => disableEmail(user.id)}
>
<Mail size={11} />
Email
</Button>
) : (
<Button
size="sm"
variant="ghost-dark"
onClick={() => toggleEmail(user.id)}
>
<Mail size={11} />
Email
</Button>
)}
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import ReactMarkdown from "react-markdown";
import { cn } from "../lib/utils";
type AnswerBubbleProps = {
text: string;
@@ -7,25 +8,32 @@ type AnswerBubbleProps = {
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
return (
<div className="rounded-md bg-orange-100 p-3 sm:p-4 w-2/3">
<div className="flex justify-start message-enter">
<div
className={cn(
"max-w-[78%] rounded-3xl rounded-bl-md",
"bg-warm-white border border-sand-light/70",
"shadow-sm shadow-sand/30",
"overflow-hidden",
)}
>
{/* amber accent bar */}
<div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" />
<div className="px-4 py-3">
{loading ? (
<div className="flex flex-col w-full animate-pulse gap-2">
<div className="flex flex-row gap-2 w-full">
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
</div>
<div className="flex flex-row gap-2 w-full">
<div className="bg-gray-400 w-1/3 p-3 rounded-lg" />
<div className="bg-gray-400 w-2/3 p-3 rounded-lg" />
</div>
<div className="flex items-center gap-1.5 py-1 px-1">
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
</div>
) : (
<div className=" flex flex-col break-words overflow-wrap-anywhere text-sm sm:text-base [&>*]:break-words">
<ReactMarkdown>
{"🐈: " + text}
</ReactMarkdown>
<div className="markdown-content text-sm leading-relaxed text-charcoal">
<ReactMarkdown>{text}</ReactMarkdown>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,20 +1,20 @@
import { useEffect, useState, useRef } from "react";
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
import { conversationService } from "../api/conversationService";
import { userService } from "../api/userService";
import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble";
import { ToolBubble } from "./ToolBubble";
import { MessageInput } from "./MessageInput";
import { ConversationList } from "./ConversationList";
import { AdminPanel } from "./AdminPanel";
import { cn } from "../lib/utils";
import catIcon from "../assets/cat.png";
type Message = {
text: string;
speaker: "simba" | "user" | "tool";
};
type QuestionAnswer = {
question: string;
answer: string;
image_key?: string | null;
};
type Conversation = {
@@ -46,20 +46,17 @@ const TOOL_MESSAGES: Record<string, string> = {
export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const [query, setQuery] = useState<string>("");
const [answer, setAnswer] = useState<string>("");
const [simbaMode, setSimbaMode] = useState<boolean>(false);
const [questionsAnswers, setQuestionsAnswers] = useState<QuestionAnswer[]>(
[],
);
const [messages, setMessages] = useState<Message[]>([]);
const [conversations, setConversations] = useState<Conversation[]>([
{ title: "simba meow meow", id: "uuid" },
]);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [showConversations, setShowConversations] = useState<boolean>(false);
const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [showAdminPanel, setShowAdminPanel] = useState<boolean>(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const isMountedRef = useRef<boolean>(true);
@@ -70,65 +67,49 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
// Cleanup effect to handle component unmounting
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
// Abort any pending requests when component unmounts
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current?.abort();
};
}, []);
const handleSelectConversation = (conversation: Conversation) => {
setShowConversations(false);
setSelectedConversation(conversation);
const loadMessages = async () => {
const load = async () => {
try {
const fetchedConversation = await conversationService.getConversation(
conversation.id,
);
const fetched = await conversationService.getConversation(conversation.id);
setMessages(
fetchedConversation.messages.map((message) => ({
text: message.text,
speaker: message.speaker,
})),
fetched.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })),
);
} catch (error) {
console.error("Failed to load messages:", error);
} catch (err) {
console.error("Failed to load messages:", err);
}
};
loadMessages();
load();
};
const loadConversations = async () => {
try {
const fetchedConversations =
await conversationService.getAllConversations();
const parsedConversations = fetchedConversations.map((conversation) => ({
id: conversation.id,
title: conversation.name,
}));
setConversations(parsedConversations);
setSelectedConversation(parsedConversations[0]);
} catch (error) {
console.error("Failed to load messages:", error);
const fetched = await conversationService.getAllConversations();
const parsed = fetched.map((c) => ({ id: c.id, title: c.name }));
setConversations(parsed);
} catch (err) {
console.error("Failed to load conversations:", err);
}
};
const handleCreateNewConversation = async () => {
const newConversation = await conversationService.createConversation();
const newConv = await conversationService.createConversation();
await loadConversations();
setSelectedConversation({
title: newConversation.name,
id: newConversation.id,
});
setSelectedConversation({ title: newConv.name, id: newConv.id });
};
useEffect(() => {
loadConversations();
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
}, []);
useEffect(() => {
@@ -136,95 +117,101 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
}, [messages]);
useEffect(() => {
const loadMessages = async () => {
if (selectedConversation == null) return;
const load = async () => {
if (!selectedConversation) return;
try {
const conversation = await conversationService.getConversation(
selectedConversation.id,
);
// Update the conversation title in case it changed
setSelectedConversation({
id: conversation.id,
title: conversation.name,
});
setMessages(
conversation.messages.map((message) => ({
text: message.text,
speaker: message.speaker,
})),
);
} catch (error) {
console.error("Failed to load messages:", error);
const conv = await conversationService.getConversation(selectedConversation.id);
setSelectedConversation({ id: conv.id, title: conv.name });
setMessages(conv.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })));
} catch (err) {
console.error("Failed to load messages:", err);
}
};
loadMessages();
load();
}, [selectedConversation?.id]);
const handleQuestionSubmit = async () => {
if (!query.trim() || isLoading) return; // Don't submit empty messages or while loading
if ((!query.trim() && !pendingImage) || isLoading) return;
let activeConversation = selectedConversation;
if (!activeConversation) {
const newConv = await conversationService.createConversation();
activeConversation = { title: newConv.name, id: newConv.id };
setSelectedConversation(activeConversation);
setConversations((prev) => [activeConversation!, ...prev]);
}
// Capture pending image before clearing state
const imageFile = pendingImage;
const currMessages = messages.concat([{ text: query, speaker: "user" }]);
setMessages(currMessages);
setQuery(""); // Clear input immediately after submission
setQuery("");
setPendingImage(null);
setIsLoading(true);
if (simbaMode) {
const randomIndex = Math.floor(Math.random() * simbaAnswers.length);
const randomElement = simbaAnswers[randomIndex];
setAnswer(randomElement);
setQuestionsAnswers(
questionsAnswers.concat([
{
question: query,
answer: randomElement,
},
]),
);
const randomElement = simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }]));
setIsLoading(false);
return;
}
// Create a new AbortController for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
// Upload image first if present
let imageKey: string | undefined;
if (imageFile) {
const uploadResult = await conversationService.uploadImage(
imageFile,
activeConversation.id,
);
imageKey = uploadResult.image_key;
// Update the user message with the image key
setMessages((prev) => {
const updated = [...prev];
// Find the last user message we just added
for (let i = updated.length - 1; i >= 0; i--) {
if (updated[i].speaker === "user") {
updated[i] = { ...updated[i], image_key: imageKey };
break;
}
}
return updated;
});
}
await conversationService.streamQuery(
query,
selectedConversation.id,
activeConversation.id,
(event) => {
if (!isMountedRef.current) return;
if (event.type === "tool_start") {
const friendly =
TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`;
const friendly = TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`;
setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }]));
} else if (event.type === "response") {
setMessages((prev) =>
prev.concat([{ text: event.message, speaker: "simba" }]),
);
setMessages((prev) => prev.concat([{ text: event.message, speaker: "simba" }]));
} else if (event.type === "error") {
console.error("Stream error:", event.message);
}
},
abortController.signal,
imageKey,
);
} catch (error) {
// Ignore abort errors (these are intentional cancellations)
if (error instanceof Error && error.name === "AbortError") {
console.log("Request was aborted");
} else {
console.error("Failed to send query:", error);
// If session expired, redirect to login
if (error instanceof Error && error.message.includes("Session expired")) {
setAuthenticated(false);
}
}
} finally {
// Only update loading state if component is still mounted
if (isMountedRef.current) {
setIsLoading(false);
}
// Clear the abort controller reference
if (isMountedRef.current) setIsLoading(false);
abortControllerRef.current = null;
}
};
@@ -233,10 +220,10 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setQuery(event.target.value);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Submit on Enter, but allow Shift+Enter for new line
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
const handleKeyDown = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
if (kev.key === "Enter" && !kev.shiftKey) {
kev.preventDefault();
handleQuestionSubmit();
}
};
@@ -248,30 +235,54 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
};
return (
<div className="h-screen flex flex-row bg-cream">
{/* Sidebar */}
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
{/* ── Desktop Sidebar ─────────────────────────────── */}
<aside
className={`hidden md:flex md:flex-col bg-sidebar-bg transition-all duration-300 ease-in-out ${
sidebarCollapsed ? "w-[68px]" : "w-72"
}`}
className={cn(
"hidden md:flex md:flex-col",
"bg-sidebar-bg transition-all duration-300 ease-in-out",
sidebarCollapsed ? "w-[56px]" : "w-64",
)}
>
{!sidebarCollapsed ? (
<div className="flex flex-col h-full">
{/* Sidebar header */}
<div className="flex items-center gap-3 px-5 py-5 border-b border-white/10">
{sidebarCollapsed ? (
/* Collapsed state */
<div className="flex flex-col items-center py-4 gap-4 h-full">
<button
onClick={() => setSidebarCollapsed(false)}
className="w-9 h-9 rounded-xl flex items-center justify-center text-cream/50 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
>
<PanelLeftOpen size={18} />
</button>
<img
src={catIcon}
alt="Simba"
className="w-9 h-9 cursor-pointer hover:scale-110 transition-transform duration-200 flex-shrink-0"
onClick={() => setSidebarCollapsed(true)}
className="w-12 h-12 opacity-70 mt-1"
/>
<h2 className="font-[family-name:var(--font-display)] text-xl font-bold text-cream tracking-tight">
</div>
) : (
/* Expanded state */
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
<div className="flex items-center gap-2.5">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
<h2
className="text-lg font-bold text-cream tracking-tight"
style={{ fontFamily: "var(--font-display)" }}
>
asksimba
</h2>
</div>
<button
onClick={() => setSidebarCollapsed(true)}
className="w-7 h-7 rounded-lg flex items-center justify-center text-cream/40 hover:text-cream hover:bg-white/10 transition-all cursor-pointer"
>
<PanelLeftClose size={15} />
</button>
</div>
{/* Conversations */}
<div className="flex-1 overflow-y-auto px-3 py-3">
<div className="flex-1 overflow-y-auto px-2 py-3">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
@@ -280,97 +291,115 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
/>
</div>
{/* Logout */}
<div className="px-3 pb-4 pt-2 border-t border-white/10">
{/* Footer */}
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
{isAdmin && (
<button
className="w-full py-2.5 px-3 text-sm text-cream/60 hover:text-cream hover:bg-white/5
rounded-lg transition-all duration-200 cursor-pointer"
onClick={handleLogout}
onClick={() => setShowAdminPanel(true)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
Sign out
<Shield size={14} />
<span>Admin</span>
</button>
)}
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
>
<LogOut size={14} />
<span>Sign out</span>
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center py-5 h-full">
<img
src={catIcon}
alt="Simba"
className="w-9 h-9 cursor-pointer hover:scale-110 transition-transform duration-200"
onClick={() => setSidebarCollapsed(false)}
/>
</div>
)}
</aside>
{/* Main chat area */}
<div className="flex-1 flex flex-col h-screen overflow-hidden">
{/* Admin Panel modal */}
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{/* ── Main chat area ──────────────────────────────── */}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
{/* Mobile header */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light">
<div className="flex items-center gap-2.5">
<img src={catIcon} alt="Simba" className="w-8 h-8" />
<h1 className="font-[family-name:var(--font-display)] text-lg font-bold text-charcoal">
<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" />
<h1
className="text-base font-bold text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
asksimba
</h1>
</div>
<div className="flex items-center gap-2">
<button
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-cream-dark text-charcoal
hover:bg-sand-light transition-colors cursor-pointer"
onClick={() => setShowConversations(!showConversations)}
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((v) => !v)}
>
{showConversations ? "Hide" : "Threads"}
{showConversations ? <X size={16} /> : <Menu size={16} />}
</button>
<button
className="px-3 py-1.5 text-xs font-medium rounded-lg text-warm-gray
hover:bg-cream-dark transition-colors cursor-pointer"
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={handleLogout}
>
Sign out
<LogOut size={15} />
</button>
</div>
</header>
{/* Conversation title bar */}
{selectedConversation && (
<div className="bg-warm-white/80 backdrop-blur-sm border-b border-sand-light/50 px-6 py-3">
<h2 className="text-sm font-semibold text-charcoal truncate max-w-2xl mx-auto">
{selectedConversation.title || "Untitled Conversation"}
</h2>
</div>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-2xl mx-auto flex flex-col gap-4">
{/* Mobile conversation list */}
{messages.length === 0 ? (
/* ── Empty / homepage state ── */
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
{/* Mobile conversation drawer */}
{showConversations && (
<div className="md:hidden mb-2">
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
onSelectConversation={handleSelectConversation}
selectedId={selectedConversation?.id}
variant="light"
/>
</div>
)}
{/* Empty state */}
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<div className="relative">
<div className="absolute -inset-4 bg-amber-soft/20 rounded-full blur-2xl" />
<img
src={catIcon}
alt="Simba"
className="relative w-16 h-16 opacity-60"
<div className="absolute -inset-6 bg-amber-soft/20 rounded-full blur-3xl" />
<img src={catIcon} alt="Simba" className="relative w-36 h-36" />
</div>
<h1
className="text-2xl font-bold text-charcoal"
style={{ fontFamily: "var(--font-display)" }}
>
Ask me anything
</h1>
<div className="w-full max-w-2xl">
<MessageInput
query={query}
handleQueryChange={handleQueryChange}
handleKeyDown={handleKeyDown}
handleQuestionSubmit={handleQuestionSubmit}
setSimbaMode={setSimbaMode}
isLoading={isLoading}
pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)}
onClearImage={() => setPendingImage(null)}
/>
</div>
<div className="text-center">
<p className="text-warm-gray text-sm">
Ask Simba anything
</p>
</div>
) : (
/* ── Active chat state ── */
<>
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-2xl mx-auto flex flex-col gap-3">
{/* Mobile conversation drawer */}
{showConversations && (
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
conversations={conversations}
onCreateNewConversation={handleCreateNewConversation}
onSelectConversation={handleSelectConversation}
selectedId={selectedConversation?.id}
variant="light"
/>
</div>
)}
@@ -379,16 +408,16 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
return <ToolBubble key={index} text={msg.text} />;
if (msg.speaker === "simba")
return <AnswerBubble key={index} text={msg.text} />;
return <QuestionBubble key={index} text={msg.text} />;
return <QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />;
})}
{isLoading && <AnswerBubble text="" loading={true} />}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input area */}
<footer className="border-t border-sand-light/50 bg-warm-white/60 backdrop-blur-sm">
<div className="max-w-2xl mx-auto px-4 py-4">
<footer className="border-t border-sand-light/40 bg-cream/80 backdrop-blur-sm">
<div className="max-w-2xl mx-auto px-4 py-3">
<MessageInput
query={query}
handleQueryChange={handleQueryChange}
@@ -399,6 +428,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
/>
</div>
</footer>
</>
)}
</div>
</div>
);

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from "react";
import { Plus } from "lucide-react";
import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService";
type Conversation = {
title: string;
id: string;
@@ -10,60 +12,80 @@ type ConversationProps = {
conversations: Conversation[];
onSelectConversation: (conversation: Conversation) => void;
onCreateNewConversation: () => void;
selectedId?: string;
variant?: "dark" | "light";
};
export const ConversationList = ({
conversations,
onSelectConversation,
onCreateNewConversation,
selectedId,
variant = "dark",
}: ConversationProps) => {
const [conservations, setConversations] = useState(conversations);
const [items, setItems] = useState(conversations);
useEffect(() => {
const loadConversations = async () => {
const load = async () => {
try {
let fetchedConversations =
await conversationService.getAllConversations();
if (conversations.length == 0) {
let fetched = await conversationService.getAllConversations();
if (fetched.length === 0) {
await conversationService.createConversation();
fetchedConversations =
await conversationService.getAllConversations();
fetched = await conversationService.getAllConversations();
}
setConversations(
fetchedConversations.map((conversation) => ({
id: conversation.id,
title: conversation.name,
})),
);
} catch (error) {
console.error("Failed to load messages:", error);
setItems(fetched.map((c) => ({ id: c.id, title: c.name })));
} catch (err) {
console.error("Failed to load conversations:", err);
}
};
loadConversations();
load();
}, []);
// Keep in sync when parent updates conversations
useEffect(() => {
setItems(conversations);
}, [conversations]);
return (
<div className="bg-stone-200 rounded-md p-3 sm:p-4 flex flex-col gap-1">
{conservations.map((conversation) => {
return (
<div
key={conversation.id}
className="bg-stone-200 hover:bg-stone-300 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
onClick={() => onSelectConversation(conversation)}
<div className="flex flex-col gap-1">
{/* New thread button */}
<button
onClick={onCreateNewConversation}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 rounded-xl",
"text-sm transition-all duration-150 cursor-pointer mb-1",
variant === "dark"
? "text-cream/60 hover:text-cream hover:bg-white/8"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<p className="text-sm sm:text-base truncate w-full">
{conversation.title}
</p>
</div>
<Plus size={14} strokeWidth={2.5} />
<span>New thread</span>
</button>
{/* Conversation items */}
{items.map((conv) => {
const isActive = conv.id === selectedId;
return (
<button
key={conv.id}
onClick={() => onSelectConversation(conv)}
className={cn(
"w-full px-3 py-2 rounded-xl text-left",
"text-sm truncate transition-all duration-150 cursor-pointer",
variant === "dark"
? isActive
? "bg-white/12 text-cream font-medium"
: "text-cream/60 hover:text-cream hover:bg-white/8"
: isActive
? "bg-cream-dark text-charcoal font-medium"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
{conv.title}
</button>
);
})}
<div
className="bg-stone-200 hover:bg-stone-300 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
onClick={() => onCreateNewConversation()}
>
<p className="text-sm sm:text-base"> + Start a new thread</p>
</div>
</div>
);
};

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { userService } from "../api/userService";
import { oidcService } from "../api/oidcService";
import catIcon from "../assets/cat.png";
import { cn } from "../lib/utils";
type LoginScreenProps = {
setAuthenticated: (isAuth: boolean) => void;
@@ -14,25 +15,17 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
useEffect(() => {
const initAuth = async () => {
// First, check for OIDC callback parameters
const callbackParams = oidcService.getCallbackParamsFromURL();
if (callbackParams) {
// Handle OIDC callback
try {
setIsLoggingIn(true);
const result = await oidcService.handleCallback(
callbackParams.code,
callbackParams.state
callbackParams.state,
);
// Store tokens
localStorage.setItem("access_token", result.access_token);
localStorage.setItem("refresh_token", result.refresh_token);
// Clear URL parameters
oidcService.clearCallbackParams();
setAuthenticated(true);
setIsChecking(false);
return;
@@ -45,15 +38,10 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
return;
}
}
// Check if user is already authenticated
const isValid = await userService.validateToken();
if (isValid) {
setAuthenticated(true);
}
if (isValid) setAuthenticated(true);
setIsChecking(false);
};
initAuth();
}, [setAuthenticated]);
@@ -61,29 +49,34 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
try {
setIsLoggingIn(true);
setError("");
// Get authorization URL from backend
const authUrl = await oidcService.initiateLogin();
// Redirect to Authelia
window.location.href = authUrl;
} catch (err) {
} catch {
setError("Failed to initiate login. Please try again.");
console.error("OIDC login error:", err);
setIsLoggingIn(false);
}
};
// Show loading state while checking authentication or processing callback
if (isChecking || isLoggingIn) {
return (
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
{/* Subtle dot grid */}
<div
className="fixed inset-0 pointer-events-none opacity-[0.035]"
style={{
backgroundImage: `radial-gradient(circle, var(--color-charcoal) 1px, transparent 0)`,
backgroundSize: "22px 22px",
}}
/>
<div className="relative">
<div className="absolute -inset-4 bg-amber-soft/30 rounded-full blur-2xl" />
<img
src={catIcon}
alt="Simba"
className="w-16 h-16 animate-bounce"
className="relative w-14 h-14 animate-bounce drop-shadow"
/>
<p className="text-warm-gray font-medium text-lg tracking-wide">
</div>
<p className="text-warm-gray text-sm tracking-wide font-medium">
{isLoggingIn ? "letting you in..." : "checking credentials..."}
</p>
</div>
@@ -91,27 +84,35 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
}
return (
<div className="h-screen bg-cream flex items-center justify-center p-4">
{/* Decorative background texture */}
<div className="fixed inset-0 opacity-[0.03] pointer-events-none"
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
{/* Background dot texture */}
<div
className="fixed inset-0 pointer-events-none opacity-[0.04]"
style={{
backgroundImage: `radial-gradient(circle at 1px 1px, var(--color-charcoal) 1px, transparent 0)`,
backgroundSize: '24px 24px'
backgroundImage: `radial-gradient(circle, var(--color-charcoal) 1px, transparent 0)`,
backgroundSize: "22px 22px",
}}
/>
{/* Decorative background blobs */}
<div className="absolute top-1/4 -left-20 w-72 h-72 rounded-full bg-leaf-pale/60 blur-3xl pointer-events-none" />
<div className="absolute bottom-1/4 -right-20 w-64 h-64 rounded-full bg-amber-pale/70 blur-3xl pointer-events-none" />
<div className="relative w-full max-w-sm">
{/* Cat icon & branding */}
{/* Branding */}
<div className="flex flex-col items-center mb-8">
<div className="relative mb-4">
<div className="absolute -inset-3 bg-amber-soft/40 rounded-full blur-xl" />
<div className="relative mb-5">
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
<img
src={catIcon}
alt="Simba"
className="relative w-20 h-20 drop-shadow-lg"
/>
</div>
<h1 className="font-[family-name:var(--font-display)] text-4xl font-bold text-charcoal tracking-tight">
<h1
className="text-4xl font-bold text-charcoal tracking-tight"
style={{ fontFamily: "var(--font-display)" }}
>
asksimba
</h1>
<p className="text-warm-gray text-sm mt-1.5 tracking-wide">
@@ -119,10 +120,15 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
</p>
</div>
{/* Login card */}
<div className="bg-warm-white rounded-2xl shadow-lg shadow-sand/40 border border-sand-light/60 p-8">
{/* Card */}
<div
className={cn(
"bg-warm-white rounded-3xl border border-sand-light",
"shadow-xl shadow-sand/30 p-8",
)}
>
{error && (
<div className="mb-4 text-sm bg-red-50 text-red-700 p-3 rounded-xl border border-red-200">
<div className="mb-5 text-sm bg-red-50 text-red-600 px-4 py-3 rounded-2xl border border-red-200">
{error}
</div>
)}
@@ -132,21 +138,23 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
</p>
<button
className="w-full py-3.5 px-4 bg-forest text-white font-semibold rounded-xl
hover:bg-forest-light transition-all duration-200
active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed
shadow-md shadow-forest/20 hover:shadow-lg hover:shadow-forest/30
cursor-pointer text-sm tracking-wide"
onClick={handleOIDCLogin}
disabled={isLoggingIn}
className={cn(
"w-full py-3.5 px-4 rounded-2xl text-sm font-semibold tracking-wide",
"bg-forest text-cream",
"shadow-md shadow-forest/20",
"hover:bg-forest-mid hover:shadow-lg hover:shadow-forest/30",
"active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed",
"transition-all duration-200 cursor-pointer",
)}
>
{isLoggingIn ? "Redirecting..." : "Sign in with Authelia"}
</button>
</div>
{/* Footer paw prints */}
<p className="text-center text-sand mt-6 text-xs tracking-widest select-none">
~ meow ~
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
meow
</p>
</div>
</div>

View File

@@ -1,12 +1,18 @@
import { useEffect, useState, useRef } from "react";
import { useRef, useState } from "react";
import { ArrowUp, ImagePlus, X } from "lucide-react";
import { cn } from "../lib/utils";
import { Textarea } from "./ui/textarea";
type MessageInputProps = {
handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleKeyDown: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleQuestionSubmit: () => void;
setSimbaMode: (sdf: boolean) => void;
setSimbaMode: (val: boolean) => void;
query: string;
isLoading: boolean;
pendingImage: File | null;
onImageSelect: (file: File) => void;
onClearImage: () => void;
};
export const MessageInput = ({
@@ -16,40 +22,126 @@ export const MessageInput = ({
handleQuestionSubmit,
setSimbaMode,
isLoading,
pendingImage,
onImageSelect,
onClearImage,
}: MessageInputProps) => {
const [simbaMode, setLocalSimbaMode] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const toggleSimbaMode = () => {
const next = !simbaMode;
setLocalSimbaMode(next);
setSimbaMode(next);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onImageSelect(file);
}
// Reset so the same file can be re-selected
e.target.value = "";
};
const canSend = !isLoading && (query.trim() || pendingImage);
return (
<div className="flex flex-col gap-4 sticky bottom-0 bg-[#3D763A] p-6 rounded-xl">
<div className="flex flex-row justify-between grow">
<textarea
className="p-3 sm:p-4 border border-blue-200 rounded-md grow bg-[#F9F5EB] min-h-[44px] resize-y"
<div
className={cn(
"rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30",
"transition-shadow duration-200 focus-within:shadow-lg focus-within:shadow-amber-soft/20",
"focus-within:border-amber-soft/60",
)}
>
{/* Image preview */}
{pendingImage && (
<div className="px-3 pt-3">
<div className="relative inline-block">
<img
src={URL.createObjectURL(pendingImage)}
alt="Pending upload"
className="h-20 rounded-lg object-cover border border-sand"
/>
<button
type="button"
onClick={onClearImage}
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} />
</button>
</div>
</div>
)}
{/* Textarea */}
<Textarea
onChange={handleQueryChange}
onKeyDown={handleKeyDown}
value={query}
rows={2}
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
placeholder="Ask Simba anything..."
className="min-h-[60px] max-h-40"
/>
</div>
<div className="flex flex-row justify-between gap-2 grow">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-3 pb-2.5 pt-1">
<div className="flex items-center gap-3">
{/* Simba mode toggle */}
<button
className={`p-3 sm:p-4 min-h-[44px] border border-blue-400 rounded-md flex-grow text-sm sm:text-base ${
isLoading
? "bg-gray-400 cursor-not-allowed opacity-50"
: "bg-[#EDA541] hover:bg-blue-400 cursor-pointer"
}`}
onClick={() => handleQuestionSubmit()}
type="submit"
disabled={isLoading}
type="button"
onClick={toggleSimbaMode}
className="flex items-center gap-2 group cursor-pointer select-none"
>
{isLoading ? "Sending..." : "Submit"}
<div className={cn("toggle-track", simbaMode && "checked")}>
<div className="toggle-thumb" />
</div>
<span className="text-xs text-warm-gray group-hover:text-charcoal transition-colors">
simba mode
</span>
</button>
{/* Image attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className={cn(
"w-7 h-7 rounded-lg flex items-center justify-center transition-all cursor-pointer",
isLoading
? "text-warm-gray/40 cursor-not-allowed"
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
)}
>
<ImagePlus size={16} />
</button>
</div>
<div className="flex flex-row justify-center gap-2 grow items-center">
<input
type="checkbox"
onChange={(event) => setSimbaMode(event.target.checked)}
className="w-5 h-5 cursor-pointer"
/>
<p className="text-sm sm:text-base">simba mode?</p>
{/* Send button */}
<button
type="submit"
onClick={handleQuestionSubmit}
disabled={!canSend}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center",
"transition-all duration-200 cursor-pointer",
"shadow-sm",
!canSend
? "bg-sand text-warm-gray/50 cursor-not-allowed shadow-none"
: "bg-amber-glow text-white hover:bg-amber-dark hover:shadow-md hover:shadow-amber-glow/30 active:scale-95",
)}
>
<ArrowUp size={15} strokeWidth={2.5} />
</button>
</div>
</div>
);

View File

@@ -1,11 +1,31 @@
import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService";
type QuestionBubbleProps = {
text: string;
image_key?: string | null;
};
export const QuestionBubble = ({ text }: QuestionBubbleProps) => {
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
return (
<div className="w-2/3 rounded-md bg-stone-200 p-3 sm:p-4 break-words overflow-wrap-anywhere text-sm sm:text-base ml-auto">
🤦: {text}
<div className="flex justify-end message-enter">
<div
className={cn(
"max-w-[72%] rounded-3xl rounded-br-md",
"bg-leaf-pale border border-leaf-light/60",
"px-4 py-3 text-sm leading-relaxed text-charcoal",
"shadow-sm shadow-leaf/10",
)}
>
{image_key && (
<img
src={conversationService.getImageUrl(image_key)}
alt="Uploaded image"
className="max-w-full rounded-xl mb-2"
/>
)}
{text}
</div>
</div>
);
};

View File

@@ -1,5 +1,15 @@
import { cn } from "../lib/utils";
export const ToolBubble = ({ text }: { text: string }) => (
<div className="text-sm text-gray-500 italic px-3 py-1 self-start">
<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",
)}
>
{text}
</div>
</div>
);

View File

@@ -0,0 +1,26 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "bg-leaf-pale text-leaf-dark border border-leaf-light/50",
amber: "bg-amber-pale text-amber-glow border border-amber-soft/40",
muted: "bg-sand-light/60 text-warm-gray border border-sand/40",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export const Badge = ({ className, variant, ...props }: BadgeProps) => {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
};

View File

@@ -0,0 +1,48 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none",
{
variants: {
variant: {
default:
"bg-leaf text-white shadow-sm shadow-leaf/20 hover:bg-leaf-dark hover:shadow-md hover:shadow-leaf/30 active:scale-[0.97]",
amber:
"bg-amber-glow text-white shadow-sm shadow-amber/20 hover:bg-amber-dark hover:shadow-md active:scale-[0.97]",
ghost:
"text-cream/70 hover:text-cream hover:bg-white/8 active:scale-[0.97]",
"ghost-dark":
"text-warm-gray hover:text-charcoal hover:bg-sand-light/60 active:scale-[0.97]",
outline:
"border border-sand bg-transparent text-warm-gray hover:bg-cream-dark hover:text-charcoal active:scale-[0.97]",
destructive:
"text-red-400 hover:text-red-600 hover:bg-red-50 active:scale-[0.97]",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-7 px-3 text-xs",
lg: "h-11 px-6 text-base",
icon: "h-9 w-9",
"icon-sm": "h-7 w-7",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = ({ className, variant, size, ...props }: ButtonProps) => {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
};

View File

@@ -0,0 +1,19 @@
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = ({ className, ...props }: InputProps) => {
return (
<input
className={cn(
"flex h-8 w-full rounded-lg border border-sand bg-cream px-3 py-1",
"text-sm text-charcoal placeholder:text-warm-gray/50",
"focus:outline-none focus:ring-2 focus:ring-amber-soft/60",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
};

View File

@@ -0,0 +1,37 @@
import { cn } from "../../lib/utils";
export const Table = ({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
);
export const TableHeader = ({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<thead className={cn("[&_tr]:border-b [&_tr]:border-sand-light", className)} {...props} />
);
export const TableBody = ({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) => (
<tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />
);
export const TableRow = ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr
className={cn(
"border-b border-sand-light/50 transition-colors hover:bg-cream-dark/40",
className,
)}
{...props}
/>
);
export const TableHead = ({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th
className={cn(
"h-10 px-4 text-left align-middle text-xs font-semibold text-warm-gray uppercase tracking-wider",
className,
)}
{...props}
/>
);
export const TableCell = ({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td className={cn("px-4 py-3 align-middle", className)} {...props} />
);

View File

@@ -0,0 +1,19 @@
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export const Textarea = ({ className, ...props }: TextareaProps) => {
return (
<textarea
className={cn(
"flex w-full resize-none rounded-xl border-0 bg-transparent px-3 py-2.5",
"text-sm text-charcoal placeholder:text-warm-gray/50",
"focus:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
};

View File

@@ -11,3 +11,9 @@ if (rootEl) {
</React.StrictMode>,
);
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(console.warn);
});
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

File diff suppressed because it is too large Load Diff

View File

@@ -76,6 +76,50 @@ def describe_simba_image(input):
return result
async def analyze_user_image(file_bytes: bytes) -> str:
"""Analyze an image uploaded by a user and return a text description.
Uses llama-server (OpenAI-compatible API) with vision support.
Falls back to OpenAI if llama-server is not configured.
"""
import base64
from openai import AsyncOpenAI
llama_url = os.getenv("LLAMA_SERVER_URL")
if llama_url:
aclient = AsyncOpenAI(base_url=llama_url, api_key="not-needed")
model = os.getenv("LLAMA_MODEL_NAME", "llama-3.1-8b-instruct")
else:
aclient = AsyncOpenAI()
model = "gpt-4o-mini"
b64 = base64.b64encode(file_bytes).decode("utf-8")
response = await aclient.chat.completions.create(
model=model,
messages=[
{
"role": "system",
"content": "You are a helpful image analyst. Describe what you see in the image in detail. Be thorough but concise.",
},
{
"role": "user",
"content": [
{"type": "text", "text": "Please describe this image in detail."},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{b64}",
},
},
],
},
],
)
return response.choices[0].message.content
if __name__ == "__main__":
args = parser.parse_args()
if args.filepath:

62
utils/image_upload.py Normal file
View File

@@ -0,0 +1,62 @@
import io
import logging
from PIL import Image
from pillow_heif import register_heif_opener
register_heif_opener()
logging.basicConfig(level=logging.INFO)
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"}
MAX_DIMENSION = 1920
class ImageValidationError(Exception):
pass
def process_image(file_bytes: bytes, content_type: str) -> tuple[bytes, str]:
"""Validate, resize, and strip EXIF from an uploaded image.
Returns processed bytes and the output content type (always image/jpeg or image/png or image/webp).
"""
if content_type not in ALLOWED_TYPES:
raise ImageValidationError(
f"Unsupported image type: {content_type}. "
f"Allowed: JPEG, PNG, WebP, HEIC"
)
img = Image.open(io.BytesIO(file_bytes))
# Resize if too large
width, height = img.size
if max(width, height) > MAX_DIMENSION:
ratio = MAX_DIMENSION / max(width, height)
new_size = (int(width * ratio), int(height * ratio))
img = img.resize(new_size, Image.LANCZOS)
logging.info(
f"Resized image from {width}x{height} to {new_size[0]}x{new_size[1]}"
)
# Strip EXIF by copying pixel data to a new image
clean_img = Image.new(img.mode, img.size)
clean_img.putdata(list(img.getdata()))
# Convert HEIC/HEIF to JPEG; otherwise keep original format
if content_type in {"image/heic", "image/heif"}:
output_format = "JPEG"
output_content_type = "image/jpeg"
elif content_type == "image/png":
output_format = "PNG"
output_content_type = "image/png"
elif content_type == "image/webp":
output_format = "WEBP"
output_content_type = "image/webp"
else:
output_format = "JPEG"
output_content_type = "image/jpeg"
buf = io.BytesIO()
clean_img.save(buf, format=output_format, quality=85)
return buf.getvalue(), output_content_type

53
utils/s3_client.py Normal file
View File

@@ -0,0 +1,53 @@
import os
import logging
import aioboto3
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO)
S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL")
S3_ACCESS_KEY_ID = os.getenv("S3_ACCESS_KEY_ID")
S3_SECRET_ACCESS_KEY = os.getenv("S3_SECRET_ACCESS_KEY")
S3_BUCKET_NAME = os.getenv("S3_BUCKET_NAME", "asksimba-images")
S3_REGION = os.getenv("S3_REGION", "garage")
session = aioboto3.Session()
def _get_client():
return session.client(
"s3",
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
region_name=S3_REGION,
)
async def upload_image(file_bytes: bytes, key: str, content_type: str) -> str:
async with _get_client() as client:
await client.put_object(
Bucket=S3_BUCKET_NAME,
Key=key,
Body=file_bytes,
ContentType=content_type,
)
logging.info(f"Uploaded image to S3: {key}")
return key
async def get_image(key: str) -> tuple[bytes, str]:
async with _get_client() as client:
response = await client.get_object(Bucket=S3_BUCKET_NAME, Key=key)
body = await response["Body"].read()
content_type = response.get("ContentType", "image/jpeg")
return body, content_type
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}")

View File

@@ -27,18 +27,14 @@ class YNABService:
self.api_client = ynab.ApiClient(configuration)
# Initialize API endpoints
self.budgets_api = ynab.BudgetsApi(self.api_client)
self.plans_api = ynab.PlansApi(self.api_client)
self.transactions_api = ynab.TransactionsApi(self.api_client)
self.months_api = ynab.MonthsApi(self.api_client)
self.categories_api = ynab.CategoriesApi(self.api_client)
# Get budget ID if not provided
# Get budget ID if not provided, fall back to last-used
if not self.budget_id:
budgets_response = self.budgets_api.get_budgets()
if budgets_response.data and budgets_response.data.budgets:
self.budget_id = budgets_response.data.budgets[0].id
else:
raise ValueError("No YNAB budgets found")
self.budget_id = "last-used"
def get_budget_summary(self) -> dict[str, Any]:
"""Get overall budget summary and health status.
@@ -47,7 +43,7 @@ class YNABService:
Dictionary containing budget summary with to-be-budgeted amount,
total budgeted, total activity, and overall budget health.
"""
budget_response = self.budgets_api.get_budget_by_id(self.budget_id)
budget_response = self.plans_api.get_plan_by_id(self.budget_id)
budget_data = budget_response.data.budget
# Calculate totals from categories
@@ -59,10 +55,7 @@ class YNABService:
total_activity = 0
total_available = 0
for category_group in budget_data.category_groups or []:
if category_group.deleted or category_group.hidden:
continue
for category in category_group.categories or []:
for category in budget_data.categories or []:
if category.deleted or category.hidden:
continue
total_budgeted += category.budgeted / 1000
@@ -89,7 +82,6 @@ class YNABService:
end_date: Optional[str] = None,
category_name: Optional[str] = None,
payee_name: Optional[str] = None,
limit: int = 50,
) -> dict[str, Any]:
"""Get transactions filtered by date range, category, or payee.
@@ -98,7 +90,6 @@ class YNABService:
end_date: End date in YYYY-MM-DD format (defaults to today)
category_name: Filter by category name (case-insensitive partial match)
payee_name: Filter by payee name (case-insensitive partial match)
limit: Maximum number of transactions to return (default 50)
Returns:
Dictionary containing matching transactions and summary statistics.
@@ -109,9 +100,10 @@ class YNABService:
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
# Get transactions
# Get transactions (SDK v2 requires datetime.date, not string)
since_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date()
transactions_response = self.transactions_api.get_transactions(
self.budget_id, since_date=start_date
self.budget_id, since_date=since_date_obj
)
transactions = transactions_response.data.transactions or []
@@ -124,7 +116,7 @@ class YNABService:
# Skip if deleted or before start date or after end date
if txn.deleted:
continue
txn_date = str(txn.date)
txn_date = str(txn.var_date)
if txn_date < start_date or txn_date > end_date:
continue
@@ -141,7 +133,7 @@ class YNABService:
amount = txn.amount / 1000 # Convert milliunits to dollars
filtered_transactions.append(
{
"date": txn_date,
"date": str(txn.var_date),
"payee": txn.payee_name,
"category": txn.category_name,
"memo": txn.memo,
@@ -151,9 +143,8 @@ class YNABService:
)
total_amount += amount
# Sort by date (most recent first) and limit
# Sort by date (most recent first)
filtered_transactions.sort(key=lambda x: x["date"], reverse=True)
filtered_transactions = filtered_transactions[:limit]
return {
"transactions": filtered_transactions,
@@ -180,8 +171,9 @@ class YNABService:
if len(month) == 7: # YYYY-MM
month = f"{month}-01"
# Get budget month
month_response = self.months_api.get_budget_month(self.budget_id, month)
# Get budget month (SDK v2 requires datetime.date, not string)
month_date_obj = datetime.strptime(month, "%Y-%m-%d").date()
month_response = self.months_api.get_plan_month(self.budget_id, month_date_obj)
month_data = month_response.data.month