Compare commits
13 Commits
97be5262a8
...
fix/ynab-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
142fac3a84 | ||
|
|
0415610d64 | ||
|
|
ac9c821ec7 | ||
|
|
0f88d211de | ||
|
|
6917f331d8 | ||
|
|
6a7b1369ad | ||
|
|
4621755c54 | ||
|
|
b6cd4e85f0 | ||
|
|
30d7f0a060 | ||
|
|
da9b52dda1 | ||
|
|
d1cb55ff1a | ||
|
|
53b2b3b366 | ||
|
|
03c7e0c951 |
11
.env.example
11
.env.example
@@ -70,6 +70,17 @@ TWILIO_SIGNATURE_VALIDATION=true
|
|||||||
# WHATSAPP_RATE_LIMIT_MAX=10
|
# WHATSAPP_RATE_LIMIT_MAX=10
|
||||||
# WHATSAPP_RATE_LIMIT_WINDOW=60
|
# 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)
|
# Obsidian Configuration (headless sync)
|
||||||
# Auth token from Obsidian account (Settings → Account → API token)
|
# Auth token from Obsidian account (Settings → Account → API token)
|
||||||
OBSIDIAN_AUTH_TOKEN=your-obsidian-auth-token
|
OBSIDIAN_AUTH_TOKEN=your-obsidian-auth-token
|
||||||
|
|||||||
22
app.py
22
app.py
@@ -11,7 +11,9 @@ import blueprints.conversation.logic
|
|||||||
import blueprints.rag
|
import blueprints.rag
|
||||||
import blueprints.users
|
import blueprints.users
|
||||||
import blueprints.whatsapp
|
import blueprints.whatsapp
|
||||||
|
import blueprints.email
|
||||||
import blueprints.users.models
|
import blueprints.users.models
|
||||||
|
from config.db import TORTOISE_CONFIG
|
||||||
from main import consult_simba_oracle
|
from main import consult_simba_oracle
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
@@ -27,6 +29,7 @@ app = Quart(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY")
|
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)
|
jwt = JWTManager(app)
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
@@ -34,26 +37,9 @@ app.register_blueprint(blueprints.users.user_blueprint)
|
|||||||
app.register_blueprint(blueprints.conversation.conversation_blueprint)
|
app.register_blueprint(blueprints.conversation.conversation_blueprint)
|
||||||
app.register_blueprint(blueprints.rag.rag_blueprint)
|
app.register_blueprint(blueprints.rag.rag_blueprint)
|
||||||
app.register_blueprint(blueprints.whatsapp.whatsapp_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
|
# Initialize Tortoise ORM with lifecycle hooks
|
||||||
@app.while_serving
|
@app.while_serving
|
||||||
async def lifespan():
|
async def lifespan():
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
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 (
|
from quart_jwt_extended import (
|
||||||
get_jwt_identity,
|
get_jwt_identity,
|
||||||
jwt_refresh_token_required,
|
jwt_refresh_token_required,
|
||||||
)
|
)
|
||||||
|
|
||||||
import blueprints.users.models
|
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 .agents import main_agent
|
||||||
from .logic import (
|
from .logic import (
|
||||||
@@ -20,71 +26,18 @@ from .models import (
|
|||||||
PydConversation,
|
PydConversation,
|
||||||
PydListConversation,
|
PydListConversation,
|
||||||
)
|
)
|
||||||
|
from .prompts import SIMBA_SYSTEM_PROMPT
|
||||||
|
|
||||||
conversation_blueprint = Blueprint(
|
conversation_blueprint = Blueprint(
|
||||||
"conversation_api", __name__, url_prefix="/api/conversation"
|
"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.
|
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
|
||||||
|
|
||||||
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."""
|
|
||||||
|
|
||||||
|
|
||||||
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 = (
|
recent_messages = (
|
||||||
conversation.messages[-10:]
|
conversation.messages[-10:]
|
||||||
if len(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}]
|
messages_payload = [{"role": "system", "content": _SYSTEM_PROMPT}]
|
||||||
for msg in recent_messages[:-1]: # Exclude the message we just added
|
for msg in recent_messages[:-1]: # Exclude the message we just added
|
||||||
role = "user" if msg.speaker == "user" else "assistant"
|
role = "user" if msg.speaker == "user" else "assistant"
|
||||||
messages_payload.append({"role": role, "content": msg.text})
|
text = msg.text
|
||||||
messages_payload.append({"role": "user", "content": query_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
|
return messages_payload
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +93,58 @@ async def query():
|
|||||||
return jsonify({"response": message})
|
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")
|
@conversation_blueprint.post("/stream-query")
|
||||||
@jwt_refresh_token_required
|
@jwt_refresh_token_required
|
||||||
async def stream_query():
|
async def stream_query():
|
||||||
@@ -137,16 +153,31 @@ async def stream_query():
|
|||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
query_text = data.get("query")
|
query_text = data.get("query")
|
||||||
conversation_id = data.get("conversation_id")
|
conversation_id = data.get("conversation_id")
|
||||||
|
image_key = data.get("image_key")
|
||||||
conversation = await get_conversation_by_id(conversation_id)
|
conversation = await get_conversation_by_id(conversation_id)
|
||||||
await conversation.fetch_related("messages")
|
await conversation.fetch_related("messages")
|
||||||
await add_message_to_conversation(
|
await add_message_to_conversation(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
message=query_text,
|
message=query_text or "",
|
||||||
speaker="user",
|
speaker="user",
|
||||||
user=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}
|
payload = {"messages": messages_payload}
|
||||||
|
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
@@ -215,6 +246,7 @@ async def get_conversation(conversation_id: str):
|
|||||||
"text": msg.text,
|
"text": msg.text,
|
||||||
"speaker": msg.speaker.value,
|
"speaker": msg.speaker.value,
|
||||||
"created_at": msg.created_at.isoformat(),
|
"created_at": msg.created_at.isoformat(),
|
||||||
|
"image_key": msg.image_key,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
name = conversation.name
|
name = conversation.name
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ async def add_message_to_conversation(
|
|||||||
message: str,
|
message: str,
|
||||||
speaker: str,
|
speaker: str,
|
||||||
user: blueprints.users.models.User,
|
user: blueprints.users.models.User,
|
||||||
|
image_key: str | None = None,
|
||||||
) -> ConversationMessage:
|
) -> ConversationMessage:
|
||||||
print(conversation, message, speaker)
|
print(conversation, message, speaker)
|
||||||
message = await ConversationMessage.create(
|
message = await ConversationMessage.create(
|
||||||
text=message,
|
text=message,
|
||||||
speaker=speaker,
|
speaker=speaker,
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
|
image_key=image_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class ConversationMessage(Model):
|
|||||||
)
|
)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
speaker = fields.CharEnumField(enum_type=Speaker, max_length=10)
|
speaker = fields.CharEnumField(enum_type=Speaker, max_length=10)
|
||||||
|
image_key = fields.CharField(max_length=512, null=True, default=None)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "conversation_messages"
|
table = "conversation_messages"
|
||||||
|
|||||||
57
blueprints/conversation/prompts.py
Normal file
57
blueprints/conversation/prompts.py
Normal 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."""
|
||||||
217
blueprints/email/__init__.py
Normal file
217
blueprints/email/__init__.py
Normal 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
|
||||||
14
blueprints/email/helpers.py
Normal file
14
blueprints/email/helpers.py
Normal 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}"
|
||||||
@@ -7,7 +7,9 @@ from quart_jwt_extended import (
|
|||||||
)
|
)
|
||||||
from .models import User
|
from .models import User
|
||||||
from .oidc_service import OIDCUserService
|
from .oidc_service import OIDCUserService
|
||||||
|
from .decorators import admin_required
|
||||||
from config.oidc_config import oidc_config
|
from config.oidc_config import oidc_config
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import httpx
|
import httpx
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
@@ -131,6 +133,21 @@ async def oidc_callback():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": f"ID token verification failed: {str(e)}"}), 400
|
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
|
# Get or create user from OIDC claims
|
||||||
user = await OIDCUserService.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,
|
refresh_token=refresh_token,
|
||||||
user={"id": str(user.id), "username": user.username},
|
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})
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class User(Model):
|
|||||||
email = fields.CharField(max_length=100, unique=True)
|
email = fields.CharField(max_length=100, unique=True)
|
||||||
whatsapp_number = fields.CharField(max_length=30, unique=True, null=True, index=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 fields
|
||||||
oidc_subject = fields.CharField(
|
oidc_subject = fields.CharField(
|
||||||
max_length=255, unique=True, null=True, index=True
|
max_length=255, unique=True, null=True, index=True
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from blueprints.conversation.logic import (
|
|||||||
get_conversation_transcript,
|
get_conversation_transcript,
|
||||||
)
|
)
|
||||||
from blueprints.conversation.agents import main_agent
|
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")
|
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
|
# Build messages payload for LangChain agent with system prompt and conversation history
|
||||||
try:
|
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
|
# Get last 10 messages for conversation history
|
||||||
messages = await conversation.messages.all()
|
messages = await conversation.messages.all()
|
||||||
recent_messages = list(messages)[-10:]
|
recent_messages = list(messages)[-10:]
|
||||||
|
|
||||||
# Build messages payload
|
# 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)
|
# Add recent conversation history (exclude the message we just added)
|
||||||
for msg in recent_messages[:-1]:
|
for msg in recent_messages[:-1]:
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Database configuration with environment variable support
|
DATABASE_URL = os.getenv(
|
||||||
# Use DATABASE_PATH for relative paths or DATABASE_URL for full connection strings
|
"DATABASE_URL", "postgres://raggr:raggr_dev_password@localhost:5432/raggr"
|
||||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
|
)
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite://{DATABASE_PATH}")
|
|
||||||
|
|
||||||
TORTOISE_ORM = {
|
TORTOISE_CONFIG = {
|
||||||
"connections": {"default": DATABASE_URL},
|
"connections": {"default": DATABASE_URL},
|
||||||
"apps": {
|
"apps": {
|
||||||
"models": {
|
"models": {
|
||||||
@@ -55,6 +55,12 @@ services:
|
|||||||
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
|
- OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME}
|
||||||
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
|
- OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false}
|
||||||
- OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}
|
- 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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
46
migrations/models/3_20260313000000_add_email_fields.py
Normal file
46
migrations/models/3_20260313000000_add_email_fields.py
Normal 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"
|
||||||
|
)
|
||||||
43
migrations/models/4_20260404080201_add_image_key.py
Normal file
43
migrations/models/4_20260404080201_add_image_key.py
Normal 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="
|
||||||
|
)
|
||||||
@@ -37,9 +37,10 @@ dependencies = [
|
|||||||
"ynab>=1.3.0",
|
"ynab>=1.3.0",
|
||||||
"ollama>=0.6.1",
|
"ollama>=0.6.1",
|
||||||
"twilio>=9.10.2",
|
"twilio>=9.10.2",
|
||||||
|
"aioboto3>=13.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.aerich]
|
[tool.aerich]
|
||||||
tortoise_orm = "app.TORTOISE_CONFIG"
|
tortoise_orm = "config.db.TORTOISE_CONFIG"
|
||||||
location = "./migrations"
|
location = "./migrations"
|
||||||
src_folder = "./."
|
src_folder = "./."
|
||||||
|
|||||||
@@ -12,11 +12,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
"npm-watch": "^0.13.0",
|
"npm-watch": "^0.13.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"watch": "^1.0.2"
|
"watch": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
raggr-frontend/public/apple-touch-icon.png
Normal file
BIN
raggr-frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
14
raggr-frontend/public/manifest.json
Normal file
14
raggr-frontend/public/manifest.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
raggr-frontend/public/pwa-icon-192.png
Normal file
BIN
raggr-frontend/public/pwa-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
raggr-frontend/public/pwa-icon-512.png
Normal file
BIN
raggr-frontend/public/pwa-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
46
raggr-frontend/public/sw.js
Normal file
46
raggr-frontend/public/sw.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,7 +4,16 @@ import { pluginReact } from '@rsbuild/plugin-react';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [pluginReact()],
|
plugins: [pluginReact()],
|
||||||
html: {
|
html: {
|
||||||
title: 'Raggr',
|
title: 'Ask Simba',
|
||||||
favicon: './src/assets/favicon.svg',
|
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: '.' }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 "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 {
|
@theme {
|
||||||
--color-cream: #FBF7F0;
|
/* === Animal Crossing × Claude Palette === */
|
||||||
--color-cream-dark: #F3EDE2;
|
|
||||||
|
/* Backgrounds */
|
||||||
|
--color-cream: #FAF8F2;
|
||||||
|
--color-cream-dark: #F0EBDF;
|
||||||
--color-warm-white: #FFFDF9;
|
--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-glow: #E8943A;
|
||||||
|
--color-amber-dark: #C97828;
|
||||||
--color-amber-soft: #F5C882;
|
--color-amber-soft: #F5C882;
|
||||||
--color-amber-pale: #FFF0D6;
|
--color-amber-pale: #FFF4E0;
|
||||||
--color-forest: #2D5A3D;
|
|
||||||
--color-forest-light: #3D763A;
|
/* Neutrals */
|
||||||
--color-forest-pale: #E8F5E4;
|
|
||||||
--color-charcoal: #2C2420;
|
--color-charcoal: #2C2420;
|
||||||
--color-warm-gray: #8A7E74;
|
--color-warm-gray: #7A7268;
|
||||||
--color-sand: #D4C5B0;
|
--color-sand: #DECFB8;
|
||||||
--color-sand-light: #E8DED0;
|
--color-sand-light: #EDE3D4;
|
||||||
--color-blush: #F2D1B3;
|
--color-blush: #F2D1B3;
|
||||||
--color-sidebar-bg: #2C2420;
|
|
||||||
--color-sidebar-hover: #3D352F;
|
/* Sidebar */
|
||||||
--color-sidebar-active: #4A3F38;
|
--color-sidebar-bg: #2A4D38;
|
||||||
|
--color-sidebar-hover: #345E46;
|
||||||
|
--color-sidebar-active: #3D6E52;
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
--font-display: 'Playfair Display', Georgia, serif;
|
--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;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* ── Scrollbar ─────────────────────────────────────── */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar { width: 5px; }
|
||||||
width: 6px;
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
}
|
::-webkit-scrollbar-thumb { background: var(--color-sand); border-radius: 99px; }
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-thumb:hover { background: var(--color-warm-gray); }
|
||||||
background: transparent;
|
|
||||||
}
|
/* ── Markdown in answer bubbles ─────────────────────── */
|
||||||
::-webkit-scrollbar-thumb {
|
.markdown-content p { margin: 0.5em 0; line-height: 1.7; }
|
||||||
background: var(--color-sand);
|
.markdown-content p:first-child { margin-top: 0; }
|
||||||
border-radius: 3px;
|
.markdown-content p:last-child { margin-bottom: 0; }
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--color-warm-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown content styling in answer bubbles */
|
|
||||||
.markdown-content h1,
|
.markdown-content h1,
|
||||||
.markdown-content h2,
|
.markdown-content h2,
|
||||||
.markdown-content h3 {
|
.markdown-content h3 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 1em;
|
margin: 1em 0 0.4em;
|
||||||
margin-bottom: 0.5em;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
color: var(--color-charcoal);
|
||||||
}
|
}
|
||||||
.markdown-content h1 { font-size: 1.25rem; }
|
.markdown-content h1 { font-size: 1.2rem; }
|
||||||
.markdown-content h2 { font-size: 1.1rem; }
|
.markdown-content h2 { font-size: 1.05rem; }
|
||||||
.markdown-content h3 { font-size: 1rem; }
|
.markdown-content h3 { font-size: 0.95rem; }
|
||||||
|
|
||||||
.markdown-content p {
|
|
||||||
margin: 0.5em 0;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-content ul,
|
.markdown-content ul,
|
||||||
.markdown-content ol {
|
.markdown-content ol { padding-left: 1.4em; margin: 0.5em 0; }
|
||||||
padding-left: 1.5em;
|
.markdown-content li { margin: 0.3em 0; line-height: 1.6; }
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-content li {
|
|
||||||
margin: 0.25em 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-content code {
|
.markdown-content code {
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: rgba(0,0,0,0.06);
|
||||||
padding: 0.15em 0.4em;
|
padding: 0.15em 0.4em;
|
||||||
border-radius: 4px;
|
border-radius: 5px;
|
||||||
font-size: 0.88em;
|
font-size: 0.85em;
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content pre {
|
.markdown-content pre {
|
||||||
background: var(--color-charcoal);
|
background: var(--color-charcoal);
|
||||||
color: #F3EDE2;
|
color: #F0EBDF;
|
||||||
padding: 1em;
|
padding: 1em 1.1em;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0.75em 0;
|
margin: 0.8em 0;
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-content pre code {
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
.markdown-content pre code { background: none; padding: 0; color: inherit; }
|
||||||
|
|
||||||
.markdown-content a {
|
.markdown-content a {
|
||||||
color: var(--color-forest);
|
color: var(--color-leaf-dark);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content blockquote {
|
.markdown-content blockquote {
|
||||||
border-left: 3px solid var(--color-amber-glow);
|
border-left: 3px solid var(--color-amber-soft);
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
margin: 0.75em 0;
|
margin: 0.75em 0;
|
||||||
color: var(--color-warm-gray);
|
color: var(--color-warm-gray);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading skeleton animation */
|
.markdown-content strong { font-weight: 700; }
|
||||||
@keyframes shimmer {
|
.markdown-content em { font-style: italic; }
|
||||||
0% { background-position: -200% 0; }
|
|
||||||
100% { background-position: 200% 0; }
|
/* ── 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 {
|
.skeleton-shimmer {
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg,
|
||||||
90deg,
|
|
||||||
var(--color-sand-light) 25%,
|
var(--color-sand-light) 25%,
|
||||||
var(--color-cream) 50%,
|
var(--color-cream) 50%,
|
||||||
var(--color-sand-light) 75%
|
var(--color-sand-light) 75%
|
||||||
@@ -135,36 +148,26 @@ body {
|
|||||||
animation: shimmer 1.8s ease-in-out infinite;
|
animation: shimmer 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fade-in animation for messages */
|
/* ── Toggle switch ──────────────────────────────────── */
|
||||||
@keyframes fadeSlideUp {
|
.toggle-track {
|
||||||
from {
|
width: 36px;
|
||||||
opacity: 0;
|
height: 20px;
|
||||||
transform: translateY(12px);
|
border-radius: 99px;
|
||||||
}
|
background: var(--color-sand);
|
||||||
to {
|
position: relative;
|
||||||
opacity: 1;
|
transition: background 0.2s;
|
||||||
transform: translateY(0);
|
cursor: pointer;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.toggle-track.checked { background: var(--color-leaf); }
|
||||||
.message-enter {
|
.toggle-thumb {
|
||||||
animation: fadeSlideUp 0.35s ease-out forwards;
|
width: 14px;
|
||||||
}
|
height: 14px;
|
||||||
|
background: white;
|
||||||
/* Subtle pulse for loading dots */
|
border-radius: 99px;
|
||||||
@keyframes catPulse {
|
position: absolute;
|
||||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
top: 3px;
|
||||||
40% { opacity: 1; transform: scale(1); }
|
left: 3px;
|
||||||
}
|
transition: transform 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||||
.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 .toggle-thumb { transform: translateX(16px); }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface Message {
|
|||||||
text: string;
|
text: string;
|
||||||
speaker: "user" | "simba";
|
speaker: "user" | "simba";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
image_key?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
@@ -121,17 +122,52 @@ class ConversationService {
|
|||||||
return await response.json();
|
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(
|
async streamQuery(
|
||||||
query: string,
|
query: string,
|
||||||
conversation_id: string,
|
conversation_id: string,
|
||||||
onEvent: SSEEventCallback,
|
onEvent: SSEEventCallback,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
imageKey?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const body: Record<string, string> = { query, conversation_id };
|
||||||
|
if (imageKey) {
|
||||||
|
body.image_key = imageKey;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await userService.fetchWithRefreshToken(
|
const response = await userService.fetchWithRefreshToken(
|
||||||
`${this.conversationBaseUrl}/stream-query`,
|
`${this.conversationBaseUrl}/stream-query`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ query, conversation_id }),
|
body: JSON.stringify(body),
|
||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -106,14 +106,15 @@ class UserService {
|
|||||||
async fetchWithRefreshToken(
|
async fetchWithRefreshToken(
|
||||||
url: string,
|
url: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
|
{ skipContentType = false }: { skipContentType?: boolean } = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const refreshToken = localStorage.getItem("refresh_token");
|
const refreshToken = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
// Add authorization header
|
// Add authorization header
|
||||||
const headers = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
...(skipContentType ? {} : { "Content-Type": "application/json" }),
|
||||||
...(options.headers || {}),
|
...((options.headers as Record<string, string>) || {}),
|
||||||
...(refreshToken && { Authorization: `Bearer ${refreshToken}` }),
|
...(refreshToken ? { Authorization: `Bearer ${refreshToken}` } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = await fetch(url, { ...options, headers });
|
let response = await fetch(url, { ...options, headers });
|
||||||
@@ -134,6 +135,67 @@ class UserService {
|
|||||||
|
|
||||||
return response;
|
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();
|
export const userService = new UserService();
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 91 KiB |
312
raggr-frontend/src/components/AdminPanel.tsx
Normal file
312
raggr-frontend/src/components/AdminPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
type AnswerBubbleProps = {
|
type AnswerBubbleProps = {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -7,25 +8,32 @@ type AnswerBubbleProps = {
|
|||||||
|
|
||||||
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-orange-100 p-3 sm:p-4 w-2/3">
|
<div className="flex justify-start message-enter">
|
||||||
{loading ? (
|
<div
|
||||||
<div className="flex flex-col w-full animate-pulse gap-2">
|
className={cn(
|
||||||
<div className="flex flex-row gap-2 w-full">
|
"max-w-[78%] rounded-3xl rounded-bl-md",
|
||||||
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
|
"bg-warm-white border border-sand-light/70",
|
||||||
<div className="bg-gray-400 w-1/2 p-3 rounded-lg" />
|
"shadow-sm shadow-sand/30",
|
||||||
</div>
|
"overflow-hidden",
|
||||||
<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" />
|
{/* amber accent bar */}
|
||||||
</div>
|
<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 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="markdown-content text-sm leading-relaxed text-charcoal">
|
||||||
|
<ReactMarkdown>{text}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className=" flex flex-col break-words overflow-wrap-anywhere text-sm sm:text-base [&>*]:break-words">
|
|
||||||
<ReactMarkdown>
|
|
||||||
{"🐈: " + text}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||||
import { conversationService } from "../api/conversationService";
|
import { conversationService } from "../api/conversationService";
|
||||||
|
import { userService } from "../api/userService";
|
||||||
import { QuestionBubble } from "./QuestionBubble";
|
import { QuestionBubble } from "./QuestionBubble";
|
||||||
import { AnswerBubble } from "./AnswerBubble";
|
import { AnswerBubble } from "./AnswerBubble";
|
||||||
import { ToolBubble } from "./ToolBubble";
|
import { ToolBubble } from "./ToolBubble";
|
||||||
import { MessageInput } from "./MessageInput";
|
import { MessageInput } from "./MessageInput";
|
||||||
import { ConversationList } from "./ConversationList";
|
import { ConversationList } from "./ConversationList";
|
||||||
|
import { AdminPanel } from "./AdminPanel";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
import catIcon from "../assets/cat.png";
|
import catIcon from "../assets/cat.png";
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
text: string;
|
text: string;
|
||||||
speaker: "simba" | "user" | "tool";
|
speaker: "simba" | "user" | "tool";
|
||||||
};
|
image_key?: string | null;
|
||||||
|
|
||||||
type QuestionAnswer = {
|
|
||||||
question: string;
|
|
||||||
answer: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Conversation = {
|
type Conversation = {
|
||||||
@@ -46,20 +46,17 @@ const TOOL_MESSAGES: Record<string, string> = {
|
|||||||
|
|
||||||
export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||||
const [query, setQuery] = useState<string>("");
|
const [query, setQuery] = useState<string>("");
|
||||||
const [answer, setAnswer] = useState<string>("");
|
|
||||||
const [simbaMode, setSimbaMode] = useState<boolean>(false);
|
const [simbaMode, setSimbaMode] = useState<boolean>(false);
|
||||||
const [questionsAnswers, setQuestionsAnswers] = useState<QuestionAnswer[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
{ title: "simba meow meow", id: "uuid" },
|
|
||||||
]);
|
|
||||||
const [showConversations, setShowConversations] = useState<boolean>(false);
|
const [showConversations, setShowConversations] = useState<boolean>(false);
|
||||||
const [selectedConversation, setSelectedConversation] =
|
const [selectedConversation, setSelectedConversation] =
|
||||||
useState<Conversation | null>(null);
|
useState<Conversation | null>(null);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
||||||
const [isLoading, setIsLoading] = 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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const isMountedRef = useRef<boolean>(true);
|
const isMountedRef = useRef<boolean>(true);
|
||||||
@@ -70,65 +67,49 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cleanup effect to handle component unmounting
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false;
|
isMountedRef.current = false;
|
||||||
// Abort any pending requests when component unmounts
|
abortControllerRef.current?.abort();
|
||||||
if (abortControllerRef.current) {
|
|
||||||
abortControllerRef.current.abort();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectConversation = (conversation: Conversation) => {
|
const handleSelectConversation = (conversation: Conversation) => {
|
||||||
setShowConversations(false);
|
setShowConversations(false);
|
||||||
setSelectedConversation(conversation);
|
setSelectedConversation(conversation);
|
||||||
const loadMessages = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const fetchedConversation = await conversationService.getConversation(
|
const fetched = await conversationService.getConversation(conversation.id);
|
||||||
conversation.id,
|
|
||||||
);
|
|
||||||
setMessages(
|
setMessages(
|
||||||
fetchedConversation.messages.map((message) => ({
|
fetched.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })),
|
||||||
text: message.text,
|
|
||||||
speaker: message.speaker,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error("Failed to load messages:", error);
|
console.error("Failed to load messages:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadMessages();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadConversations = async () => {
|
const loadConversations = async () => {
|
||||||
try {
|
try {
|
||||||
const fetchedConversations =
|
const fetched = await conversationService.getAllConversations();
|
||||||
await conversationService.getAllConversations();
|
const parsed = fetched.map((c) => ({ id: c.id, title: c.name }));
|
||||||
const parsedConversations = fetchedConversations.map((conversation) => ({
|
setConversations(parsed);
|
||||||
id: conversation.id,
|
} catch (err) {
|
||||||
title: conversation.name,
|
console.error("Failed to load conversations:", err);
|
||||||
}));
|
|
||||||
setConversations(parsedConversations);
|
|
||||||
setSelectedConversation(parsedConversations[0]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load messages:", error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNewConversation = async () => {
|
const handleCreateNewConversation = async () => {
|
||||||
const newConversation = await conversationService.createConversation();
|
const newConv = await conversationService.createConversation();
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
setSelectedConversation({
|
setSelectedConversation({ title: newConv.name, id: newConv.id });
|
||||||
title: newConversation.name,
|
|
||||||
id: newConversation.id,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
|
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -136,95 +117,101 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMessages = async () => {
|
const load = async () => {
|
||||||
if (selectedConversation == null) return;
|
if (!selectedConversation) return;
|
||||||
try {
|
try {
|
||||||
const conversation = await conversationService.getConversation(
|
const conv = await conversationService.getConversation(selectedConversation.id);
|
||||||
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 })));
|
||||||
// Update the conversation title in case it changed
|
} catch (err) {
|
||||||
setSelectedConversation({
|
console.error("Failed to load messages:", err);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadMessages();
|
load();
|
||||||
}, [selectedConversation?.id]);
|
}, [selectedConversation?.id]);
|
||||||
|
|
||||||
const handleQuestionSubmit = async () => {
|
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" }]);
|
const currMessages = messages.concat([{ text: query, speaker: "user" }]);
|
||||||
setMessages(currMessages);
|
setMessages(currMessages);
|
||||||
setQuery(""); // Clear input immediately after submission
|
setQuery("");
|
||||||
|
setPendingImage(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
if (simbaMode) {
|
if (simbaMode) {
|
||||||
const randomIndex = Math.floor(Math.random() * simbaAnswers.length);
|
const randomElement = simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
|
||||||
const randomElement = simbaAnswers[randomIndex];
|
setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }]));
|
||||||
setAnswer(randomElement);
|
|
||||||
setQuestionsAnswers(
|
|
||||||
questionsAnswers.concat([
|
|
||||||
{
|
|
||||||
question: query,
|
|
||||||
answer: randomElement,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new AbortController for this request
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
abortControllerRef.current = abortController;
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
try {
|
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(
|
await conversationService.streamQuery(
|
||||||
query,
|
query,
|
||||||
selectedConversation.id,
|
activeConversation.id,
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
if (event.type === "tool_start") {
|
if (event.type === "tool_start") {
|
||||||
const friendly =
|
const friendly = TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`;
|
||||||
TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`;
|
|
||||||
setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }]));
|
setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }]));
|
||||||
} else if (event.type === "response") {
|
} else if (event.type === "response") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) => prev.concat([{ text: event.message, speaker: "simba" }]));
|
||||||
prev.concat([{ text: event.message, speaker: "simba" }]),
|
|
||||||
);
|
|
||||||
} else if (event.type === "error") {
|
} else if (event.type === "error") {
|
||||||
console.error("Stream error:", event.message);
|
console.error("Stream error:", event.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
|
imageKey,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore abort errors (these are intentional cancellations)
|
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
console.log("Request was aborted");
|
console.log("Request was aborted");
|
||||||
} else {
|
} else {
|
||||||
console.error("Failed to send query:", error);
|
console.error("Failed to send query:", error);
|
||||||
// If session expired, redirect to login
|
|
||||||
if (error instanceof Error && error.message.includes("Session expired")) {
|
if (error instanceof Error && error.message.includes("Session expired")) {
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Only update loading state if component is still mounted
|
if (isMountedRef.current) setIsLoading(false);
|
||||||
if (isMountedRef.current) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
// Clear the abort controller reference
|
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -233,10 +220,10 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
setQuery(event.target.value);
|
setQuery(event.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
// Submit on Enter, but allow Shift+Enter for new line
|
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (kev.key === "Enter" && !kev.shiftKey) {
|
||||||
event.preventDefault();
|
kev.preventDefault();
|
||||||
handleQuestionSubmit();
|
handleQuestionSubmit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -248,30 +235,54 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-row bg-cream">
|
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* ── Desktop Sidebar ─────────────────────────────── */}
|
||||||
<aside
|
<aside
|
||||||
className={`hidden md:flex md:flex-col bg-sidebar-bg transition-all duration-300 ease-in-out ${
|
className={cn(
|
||||||
sidebarCollapsed ? "w-[68px]" : "w-72"
|
"hidden md:flex md:flex-col",
|
||||||
}`}
|
"bg-sidebar-bg transition-all duration-300 ease-in-out",
|
||||||
|
sidebarCollapsed ? "w-[56px]" : "w-64",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{!sidebarCollapsed ? (
|
{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-12 h-12 opacity-70 mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Expanded state */
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Sidebar header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-5 py-5 border-b border-white/10">
|
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
||||||
<img
|
<div className="flex items-center gap-2.5">
|
||||||
src={catIcon}
|
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||||
alt="Simba"
|
<h2
|
||||||
className="w-9 h-9 cursor-pointer hover:scale-110 transition-transform duration-200 flex-shrink-0"
|
className="text-lg font-bold text-cream tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--font-display)" }}
|
||||||
|
>
|
||||||
|
asksimba
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
onClick={() => setSidebarCollapsed(true)}
|
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"
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-xl font-bold text-cream tracking-tight">
|
>
|
||||||
asksimba
|
<PanelLeftClose size={15} />
|
||||||
</h2>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversations */}
|
{/* Conversations */}
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
onCreateNewConversation={handleCreateNewConversation}
|
onCreateNewConversation={handleCreateNewConversation}
|
||||||
@@ -280,125 +291,145 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Footer */}
|
||||||
<div className="px-3 pb-4 pt-2 border-t border-white/10">
|
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Shield size={14} />
|
||||||
|
<span>Admin</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<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={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"
|
||||||
>
|
>
|
||||||
Sign out
|
<LogOut size={14} />
|
||||||
|
<span>Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</aside>
|
||||||
|
|
||||||
{/* Main chat area */}
|
{/* Admin Panel modal */}
|
||||||
<div className="flex-1 flex flex-col h-screen overflow-hidden">
|
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||||
|
|
||||||
|
{/* ── Main chat area ──────────────────────────────── */}
|
||||||
|
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light">
|
<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.5">
|
<div className="flex items-center gap-2">
|
||||||
<img src={catIcon} alt="Simba" className="w-8 h-8" />
|
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||||
<h1 className="font-[family-name:var(--font-display)] text-lg font-bold text-charcoal">
|
<h1
|
||||||
|
className="text-base font-bold text-charcoal"
|
||||||
|
style={{ fontFamily: "var(--font-display)" }}
|
||||||
|
>
|
||||||
asksimba
|
asksimba
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-cream-dark text-charcoal
|
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"
|
||||||
hover:bg-sand-light transition-colors cursor-pointer"
|
onClick={() => setShowConversations((v) => !v)}
|
||||||
onClick={() => setShowConversations(!showConversations)}
|
|
||||||
>
|
>
|
||||||
{showConversations ? "Hide" : "Threads"}
|
{showConversations ? <X size={16} /> : <Menu size={16} />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1.5 text-xs font-medium rounded-lg text-warm-gray
|
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"
|
||||||
hover:bg-cream-dark transition-colors cursor-pointer"
|
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
Sign out
|
<LogOut size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Conversation title bar */}
|
{messages.length === 0 ? (
|
||||||
{selectedConversation && (
|
/* ── Empty / homepage state ── */
|
||||||
<div className="bg-warm-white/80 backdrop-blur-sm border-b border-sand-light/50 px-6 py-3">
|
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
||||||
<h2 className="text-sm font-semibold text-charcoal truncate max-w-2xl mx-auto">
|
{/* Mobile conversation drawer */}
|
||||||
{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 */}
|
|
||||||
{showConversations && (
|
{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
|
<ConversationList
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
onCreateNewConversation={handleCreateNewConversation}
|
onCreateNewConversation={handleCreateNewConversation}
|
||||||
onSelectConversation={handleSelectConversation}
|
onSelectConversation={handleSelectConversation}
|
||||||
selectedId={selectedConversation?.id}
|
selectedId={selectedConversation?.id}
|
||||||
|
variant="light"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
/* ── 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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{messages.map((msg, index) => {
|
||||||
{messages.length === 0 && !isLoading && (
|
if (msg.speaker === "tool")
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
return <ToolBubble key={index} text={msg.text} />;
|
||||||
<div className="relative">
|
if (msg.speaker === "simba")
|
||||||
<div className="absolute -inset-4 bg-amber-soft/20 rounded-full blur-2xl" />
|
return <AnswerBubble key={index} text={msg.text} />;
|
||||||
<img
|
return <QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />;
|
||||||
src={catIcon}
|
})}
|
||||||
alt="Simba"
|
|
||||||
className="relative w-16 h-16 opacity-60"
|
{isLoading && <AnswerBubble text="" loading={true} />}
|
||||||
/>
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-warm-gray text-sm">
|
|
||||||
Ask Simba anything
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{messages.map((msg, index) => {
|
<footer className="border-t border-sand-light/40 bg-cream/80 backdrop-blur-sm">
|
||||||
if (msg.speaker === "tool")
|
<div className="max-w-2xl mx-auto px-4 py-3">
|
||||||
return <ToolBubble key={index} text={msg.text} />;
|
<MessageInput
|
||||||
if (msg.speaker === "simba")
|
query={query}
|
||||||
return <AnswerBubble key={index} text={msg.text} />;
|
handleQueryChange={handleQueryChange}
|
||||||
return <QuestionBubble key={index} text={msg.text} />;
|
handleKeyDown={handleKeyDown}
|
||||||
})}
|
handleQuestionSubmit={handleQuestionSubmit}
|
||||||
{isLoading && <AnswerBubble text="" loading={true} />}
|
setSimbaMode={setSimbaMode}
|
||||||
<div ref={messagesEndRef} />
|
isLoading={isLoading}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
{/* 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">
|
|
||||||
<MessageInput
|
|
||||||
query={query}
|
|
||||||
handleQueryChange={handleQueryChange}
|
|
||||||
handleKeyDown={handleKeyDown}
|
|
||||||
handleQuestionSubmit={handleQuestionSubmit}
|
|
||||||
setSimbaMode={setSimbaMode}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
import { conversationService } from "../api/conversationService";
|
import { conversationService } from "../api/conversationService";
|
||||||
|
|
||||||
type Conversation = {
|
type Conversation = {
|
||||||
title: string;
|
title: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,60 +12,80 @@ type ConversationProps = {
|
|||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
onSelectConversation: (conversation: Conversation) => void;
|
onSelectConversation: (conversation: Conversation) => void;
|
||||||
onCreateNewConversation: () => void;
|
onCreateNewConversation: () => void;
|
||||||
|
selectedId?: string;
|
||||||
|
variant?: "dark" | "light";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConversationList = ({
|
export const ConversationList = ({
|
||||||
conversations,
|
conversations,
|
||||||
onSelectConversation,
|
onSelectConversation,
|
||||||
onCreateNewConversation,
|
onCreateNewConversation,
|
||||||
|
selectedId,
|
||||||
|
variant = "dark",
|
||||||
}: ConversationProps) => {
|
}: ConversationProps) => {
|
||||||
const [conservations, setConversations] = useState(conversations);
|
const [items, setItems] = useState(conversations);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConversations = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
let fetchedConversations =
|
let fetched = await conversationService.getAllConversations();
|
||||||
await conversationService.getAllConversations();
|
if (fetched.length === 0) {
|
||||||
|
|
||||||
if (conversations.length == 0) {
|
|
||||||
await conversationService.createConversation();
|
await conversationService.createConversation();
|
||||||
fetchedConversations =
|
fetched = await conversationService.getAllConversations();
|
||||||
await conversationService.getAllConversations();
|
|
||||||
}
|
}
|
||||||
setConversations(
|
setItems(fetched.map((c) => ({ id: c.id, title: c.name })));
|
||||||
fetchedConversations.map((conversation) => ({
|
} catch (err) {
|
||||||
id: conversation.id,
|
console.error("Failed to load conversations:", err);
|
||||||
title: conversation.name,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load messages:", error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadConversations();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Keep in sync when parent updates conversations
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(conversations);
|
||||||
|
}, [conversations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-stone-200 rounded-md p-3 sm:p-4 flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{conservations.map((conversation) => {
|
{/* 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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus size={14} strokeWidth={2.5} />
|
||||||
|
<span>New thread</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Conversation items */}
|
||||||
|
{items.map((conv) => {
|
||||||
|
const isActive = conv.id === selectedId;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={conversation.id}
|
key={conv.id}
|
||||||
className="bg-stone-200 hover:bg-stone-300 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
|
onClick={() => onSelectConversation(conv)}
|
||||||
onClick={() => onSelectConversation(conversation)}
|
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",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-sm sm:text-base truncate w-full">
|
{conv.title}
|
||||||
{conversation.title}
|
</button>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
|||||||
import { userService } from "../api/userService";
|
import { userService } from "../api/userService";
|
||||||
import { oidcService } from "../api/oidcService";
|
import { oidcService } from "../api/oidcService";
|
||||||
import catIcon from "../assets/cat.png";
|
import catIcon from "../assets/cat.png";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
type LoginScreenProps = {
|
type LoginScreenProps = {
|
||||||
setAuthenticated: (isAuth: boolean) => void;
|
setAuthenticated: (isAuth: boolean) => void;
|
||||||
@@ -14,25 +15,17 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
// First, check for OIDC callback parameters
|
|
||||||
const callbackParams = oidcService.getCallbackParamsFromURL();
|
const callbackParams = oidcService.getCallbackParamsFromURL();
|
||||||
|
|
||||||
if (callbackParams) {
|
if (callbackParams) {
|
||||||
// Handle OIDC callback
|
|
||||||
try {
|
try {
|
||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
const result = await oidcService.handleCallback(
|
const result = await oidcService.handleCallback(
|
||||||
callbackParams.code,
|
callbackParams.code,
|
||||||
callbackParams.state
|
callbackParams.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store tokens
|
|
||||||
localStorage.setItem("access_token", result.access_token);
|
localStorage.setItem("access_token", result.access_token);
|
||||||
localStorage.setItem("refresh_token", result.refresh_token);
|
localStorage.setItem("refresh_token", result.refresh_token);
|
||||||
|
|
||||||
// Clear URL parameters
|
|
||||||
oidcService.clearCallbackParams();
|
oidcService.clearCallbackParams();
|
||||||
|
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
return;
|
return;
|
||||||
@@ -45,15 +38,10 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is already authenticated
|
|
||||||
const isValid = await userService.validateToken();
|
const isValid = await userService.validateToken();
|
||||||
if (isValid) {
|
if (isValid) setAuthenticated(true);
|
||||||
setAuthenticated(true);
|
|
||||||
}
|
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
initAuth();
|
initAuth();
|
||||||
}, [setAuthenticated]);
|
}, [setAuthenticated]);
|
||||||
|
|
||||||
@@ -61,29 +49,34 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
try {
|
try {
|
||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
// Get authorization URL from backend
|
|
||||||
const authUrl = await oidcService.initiateLogin();
|
const authUrl = await oidcService.initiateLogin();
|
||||||
|
|
||||||
// Redirect to Authelia
|
|
||||||
window.location.href = authUrl;
|
window.location.href = authUrl;
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError("Failed to initiate login. Please try again.");
|
setError("Failed to initiate login. Please try again.");
|
||||||
console.error("OIDC login error:", err);
|
|
||||||
setIsLoggingIn(false);
|
setIsLoggingIn(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading state while checking authentication or processing callback
|
|
||||||
if (isChecking || isLoggingIn) {
|
if (isChecking || isLoggingIn) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||||
<img
|
{/* Subtle dot grid */}
|
||||||
src={catIcon}
|
<div
|
||||||
alt="Simba"
|
className="fixed inset-0 pointer-events-none opacity-[0.035]"
|
||||||
className="w-16 h-16 animate-bounce"
|
style={{
|
||||||
|
backgroundImage: `radial-gradient(circle, var(--color-charcoal) 1px, transparent 0)`,
|
||||||
|
backgroundSize: "22px 22px",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-warm-gray font-medium text-lg tracking-wide">
|
<div className="relative">
|
||||||
|
<div className="absolute -inset-4 bg-amber-soft/30 rounded-full blur-2xl" />
|
||||||
|
<img
|
||||||
|
src={catIcon}
|
||||||
|
alt="Simba"
|
||||||
|
className="relative w-14 h-14 animate-bounce drop-shadow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-warm-gray text-sm tracking-wide font-medium">
|
||||||
{isLoggingIn ? "letting you in..." : "checking credentials..."}
|
{isLoggingIn ? "letting you in..." : "checking credentials..."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,27 +84,35 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-cream flex items-center justify-center p-4">
|
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
{/* Decorative background texture */}
|
{/* Background dot texture */}
|
||||||
<div className="fixed inset-0 opacity-[0.03] pointer-events-none"
|
<div
|
||||||
|
className="fixed inset-0 pointer-events-none opacity-[0.04]"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `radial-gradient(circle at 1px 1px, var(--color-charcoal) 1px, transparent 0)`,
|
backgroundImage: `radial-gradient(circle, var(--color-charcoal) 1px, transparent 0)`,
|
||||||
backgroundSize: '24px 24px'
|
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">
|
<div className="relative w-full max-w-sm">
|
||||||
{/* Cat icon & branding */}
|
{/* Branding */}
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-5">
|
||||||
<div className="absolute -inset-3 bg-amber-soft/40 rounded-full blur-xl" />
|
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
|
||||||
<img
|
<img
|
||||||
src={catIcon}
|
src={catIcon}
|
||||||
alt="Simba"
|
alt="Simba"
|
||||||
className="relative w-20 h-20 drop-shadow-lg"
|
className="relative w-20 h-20 drop-shadow-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
asksimba
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-warm-gray text-sm mt-1.5 tracking-wide">
|
<p className="text-warm-gray text-sm mt-1.5 tracking-wide">
|
||||||
@@ -119,10 +120,15 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login card */}
|
{/* Card */}
|
||||||
<div className="bg-warm-white rounded-2xl shadow-lg shadow-sand/40 border border-sand-light/60 p-8">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-warm-white rounded-3xl border border-sand-light",
|
||||||
|
"shadow-xl shadow-sand/30 p-8",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -132,21 +138,23 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<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}
|
onClick={handleOIDCLogin}
|
||||||
disabled={isLoggingIn}
|
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"}
|
{isLoggingIn ? "Redirecting..." : "Sign in with Authelia"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer paw prints */}
|
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
|
||||||
<p className="text-center text-sand mt-6 text-xs tracking-widest select-none">
|
✦ meow ✦
|
||||||
~ meow ~
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 = {
|
type MessageInputProps = {
|
||||||
handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
handleQueryChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
handleKeyDown: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
handleKeyDown: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
handleQuestionSubmit: () => void;
|
handleQuestionSubmit: () => void;
|
||||||
setSimbaMode: (sdf: boolean) => void;
|
setSimbaMode: (val: boolean) => void;
|
||||||
query: string;
|
query: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
pendingImage: File | null;
|
||||||
|
onImageSelect: (file: File) => void;
|
||||||
|
onClearImage: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageInput = ({
|
export const MessageInput = ({
|
||||||
@@ -16,41 +22,127 @@ export const MessageInput = ({
|
|||||||
handleQuestionSubmit,
|
handleQuestionSubmit,
|
||||||
setSimbaMode,
|
setSimbaMode,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
pendingImage,
|
||||||
|
onImageSelect,
|
||||||
|
onClearImage,
|
||||||
}: MessageInputProps) => {
|
}: 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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 sticky bottom-0 bg-[#3D763A] p-6 rounded-xl">
|
<div
|
||||||
<div className="flex flex-row justify-between grow">
|
className={cn(
|
||||||
<textarea
|
"rounded-2xl bg-warm-white border border-sand shadow-md shadow-sand/30",
|
||||||
className="p-3 sm:p-4 border border-blue-200 rounded-md grow bg-[#F9F5EB] min-h-[44px] resize-y"
|
"transition-shadow duration-200 focus-within:shadow-lg focus-within:shadow-amber-soft/20",
|
||||||
onChange={handleQueryChange}
|
"focus-within:border-amber-soft/60",
|
||||||
onKeyDown={handleKeyDown}
|
)}
|
||||||
value={query}
|
>
|
||||||
rows={2}
|
{/* Image preview */}
|
||||||
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
|
{pendingImage && (
|
||||||
/>
|
<div className="px-3 pt-3">
|
||||||
</div>
|
<div className="relative inline-block">
|
||||||
<div className="flex flex-row justify-between gap-2 grow">
|
<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="Ask Simba anything..."
|
||||||
|
className="min-h-[60px] max-h-40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
type="button"
|
||||||
|
onClick={toggleSimbaMode}
|
||||||
|
className="flex items-center gap-2 group cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
<button
|
<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"
|
type="submit"
|
||||||
disabled={isLoading}
|
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",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isLoading ? "Sending..." : "Submit"}
|
<ArrowUp size={15} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { conversationService } from "../api/conversationService";
|
||||||
|
|
||||||
type QuestionBubbleProps = {
|
type QuestionBubbleProps = {
|
||||||
text: string;
|
text: string;
|
||||||
|
image_key?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const QuestionBubble = ({ text }: QuestionBubbleProps) => {
|
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
||||||
return (
|
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">
|
<div className="flex justify-end message-enter">
|
||||||
🤦: {text}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
export const ToolBubble = ({ text }: { text: string }) => (
|
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">
|
||||||
{text}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
26
raggr-frontend/src/components/ui/badge.tsx
Normal file
26
raggr-frontend/src/components/ui/badge.tsx
Normal 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} />;
|
||||||
|
};
|
||||||
48
raggr-frontend/src/components/ui/button.tsx
Normal file
48
raggr-frontend/src/components/ui/button.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
raggr-frontend/src/components/ui/input.tsx
Normal file
19
raggr-frontend/src/components/ui/input.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
raggr-frontend/src/components/ui/table.tsx
Normal file
37
raggr-frontend/src/components/ui/table.tsx
Normal 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} />
|
||||||
|
);
|
||||||
19
raggr-frontend/src/components/ui/textarea.tsx
Normal file
19
raggr-frontend/src/components/ui/textarea.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,3 +11,9 @@ if (rootEl) {
|
|||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(console.warn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
6
raggr-frontend/src/lib/utils.ts
Normal file
6
raggr-frontend/src/lib/utils.ts
Normal 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
@@ -76,6 +76,50 @@ def describe_simba_image(input):
|
|||||||
return result
|
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__":
|
if __name__ == "__main__":
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.filepath:
|
if args.filepath:
|
||||||
|
|||||||
62
utils/image_upload.py
Normal file
62
utils/image_upload.py
Normal 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
|
||||||
@@ -323,7 +323,7 @@ class ObsidianService:
|
|||||||
note_path.parent.mkdir(parents=True, exist_ok=True)
|
note_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
content = (
|
content = (
|
||||||
f"---\nmodified: {datetime.now().isoformat()}\n---\n"
|
f"---\nmodified: {datetime.now().isoformat()}\n---\n"
|
||||||
f"### tasks\n\n- [ ] {task_text}\n\n### log\n"
|
f"### tasks\n\n- [ ] {task_text}\n\n### log\n"
|
||||||
)
|
)
|
||||||
with open(note_path, "w", encoding="utf-8") as f:
|
with open(note_path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|||||||
53
utils/s3_client.py
Normal file
53
utils/s3_client.py
Normal 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}")
|
||||||
@@ -27,18 +27,14 @@ class YNABService:
|
|||||||
self.api_client = ynab.ApiClient(configuration)
|
self.api_client = ynab.ApiClient(configuration)
|
||||||
|
|
||||||
# Initialize API endpoints
|
# 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.transactions_api = ynab.TransactionsApi(self.api_client)
|
||||||
self.months_api = ynab.MonthsApi(self.api_client)
|
self.months_api = ynab.MonthsApi(self.api_client)
|
||||||
self.categories_api = ynab.CategoriesApi(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:
|
if not self.budget_id:
|
||||||
budgets_response = self.budgets_api.get_budgets()
|
self.budget_id = "last-used"
|
||||||
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")
|
|
||||||
|
|
||||||
def get_budget_summary(self) -> dict[str, Any]:
|
def get_budget_summary(self) -> dict[str, Any]:
|
||||||
"""Get overall budget summary and health status.
|
"""Get overall budget summary and health status.
|
||||||
@@ -47,7 +43,7 @@ class YNABService:
|
|||||||
Dictionary containing budget summary with to-be-budgeted amount,
|
Dictionary containing budget summary with to-be-budgeted amount,
|
||||||
total budgeted, total activity, and overall budget health.
|
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
|
budget_data = budget_response.data.budget
|
||||||
|
|
||||||
# Calculate totals from categories
|
# Calculate totals from categories
|
||||||
@@ -59,15 +55,12 @@ class YNABService:
|
|||||||
total_activity = 0
|
total_activity = 0
|
||||||
total_available = 0
|
total_available = 0
|
||||||
|
|
||||||
for category_group in budget_data.category_groups or []:
|
for category in budget_data.categories or []:
|
||||||
if category_group.deleted or category_group.hidden:
|
if category.deleted or category.hidden:
|
||||||
continue
|
continue
|
||||||
for category in category_group.categories or []:
|
total_budgeted += category.budgeted / 1000
|
||||||
if category.deleted or category.hidden:
|
total_activity += category.activity / 1000
|
||||||
continue
|
total_available += category.balance / 1000
|
||||||
total_budgeted += category.budgeted / 1000
|
|
||||||
total_activity += category.activity / 1000
|
|
||||||
total_available += category.balance / 1000
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"budget_name": budget_data.name,
|
"budget_name": budget_data.name,
|
||||||
@@ -89,7 +82,6 @@ class YNABService:
|
|||||||
end_date: Optional[str] = None,
|
end_date: Optional[str] = None,
|
||||||
category_name: Optional[str] = None,
|
category_name: Optional[str] = None,
|
||||||
payee_name: Optional[str] = None,
|
payee_name: Optional[str] = None,
|
||||||
limit: int = 50,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Get transactions filtered by date range, category, or payee.
|
"""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)
|
end_date: End date in YYYY-MM-DD format (defaults to today)
|
||||||
category_name: Filter by category name (case-insensitive partial match)
|
category_name: Filter by category name (case-insensitive partial match)
|
||||||
payee_name: Filter by payee 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:
|
Returns:
|
||||||
Dictionary containing matching transactions and summary statistics.
|
Dictionary containing matching transactions and summary statistics.
|
||||||
@@ -109,9 +100,10 @@ class YNABService:
|
|||||||
if not end_date:
|
if not end_date:
|
||||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
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(
|
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 []
|
transactions = transactions_response.data.transactions or []
|
||||||
@@ -124,7 +116,7 @@ class YNABService:
|
|||||||
# Skip if deleted or before start date or after end date
|
# Skip if deleted or before start date or after end date
|
||||||
if txn.deleted:
|
if txn.deleted:
|
||||||
continue
|
continue
|
||||||
txn_date = str(txn.date)
|
txn_date = str(txn.var_date)
|
||||||
if txn_date < start_date or txn_date > end_date:
|
if txn_date < start_date or txn_date > end_date:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -141,7 +133,7 @@ class YNABService:
|
|||||||
amount = txn.amount / 1000 # Convert milliunits to dollars
|
amount = txn.amount / 1000 # Convert milliunits to dollars
|
||||||
filtered_transactions.append(
|
filtered_transactions.append(
|
||||||
{
|
{
|
||||||
"date": txn_date,
|
"date": str(txn.var_date),
|
||||||
"payee": txn.payee_name,
|
"payee": txn.payee_name,
|
||||||
"category": txn.category_name,
|
"category": txn.category_name,
|
||||||
"memo": txn.memo,
|
"memo": txn.memo,
|
||||||
@@ -151,9 +143,8 @@ class YNABService:
|
|||||||
)
|
)
|
||||||
total_amount += amount
|
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.sort(key=lambda x: x["date"], reverse=True)
|
||||||
filtered_transactions = filtered_transactions[:limit]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"transactions": filtered_transactions,
|
"transactions": filtered_transactions,
|
||||||
@@ -180,8 +171,9 @@ class YNABService:
|
|||||||
if len(month) == 7: # YYYY-MM
|
if len(month) == 7: # YYYY-MM
|
||||||
month = f"{month}-01"
|
month = f"{month}-01"
|
||||||
|
|
||||||
# Get budget month
|
# Get budget month (SDK v2 requires datetime.date, not string)
|
||||||
month_response = self.months_api.get_budget_month(self.budget_id, month)
|
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
|
month_data = month_response.data.month
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user