Compare commits
19 Commits
feat/makef
...
fix/mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ae76ce68 | ||
|
|
7ee3bdef84 | ||
|
|
500c44feb1 | ||
|
|
896501deb1 | ||
|
|
c95800e65d | ||
|
|
90372a6a6d | ||
|
|
c01764243f | ||
|
|
dfaac4caf8 | ||
|
|
17c3a2f888 | ||
|
|
fa0f68e3b4 | ||
|
|
a6c698c6bd | ||
|
|
07c272c96a | ||
|
|
975a337af4 | ||
|
|
e644def141 | ||
|
|
be600e78d6 | ||
|
|
b6576fb2fd | ||
|
|
bb3ef4fe95 | ||
|
|
30db71d134 | ||
|
|
167d014ca5 |
12
app.py
12
app.py
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from quart import Quart, jsonify, render_template, request, send_from_directory
|
from quart import Quart, jsonify, render_template, request, send_from_directory
|
||||||
@@ -38,6 +39,8 @@ 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["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
|
||||||
|
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit
|
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit
|
||||||
jwt = JWTManager(app)
|
jwt = JWTManager(app)
|
||||||
|
|
||||||
@@ -132,17 +135,10 @@ async def get_messages():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
name = conversation.name
|
|
||||||
if len(messages) > 8:
|
|
||||||
name = await blueprints.conversation.logic.rename_conversation(
|
|
||||||
user=user,
|
|
||||||
conversation=conversation,
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"id": str(conversation.id),
|
"id": str(conversation.id),
|
||||||
"name": name,
|
"name": conversation.name,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"created_at": conversation.created_at.isoformat(),
|
"created_at": conversation.created_at.isoformat(),
|
||||||
"updated_at": conversation.updated_at.isoformat(),
|
"updated_at": conversation.updated_at.isoformat(),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import datetime
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from quart import Blueprint, Response, jsonify, make_response, request
|
from quart import Blueprint, 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,
|
||||||
@@ -12,6 +11,7 @@ from quart_jwt_extended import (
|
|||||||
import blueprints.users.models
|
import blueprints.users.models
|
||||||
from utils.image_process import analyze_user_image
|
from utils.image_process import analyze_user_image
|
||||||
from utils.image_upload import ImageValidationError, process_image
|
from utils.image_upload import ImageValidationError, process_image
|
||||||
|
from utils.s3_client import generate_presigned_url as s3_presigned_url
|
||||||
from utils.s3_client import get_image as s3_get_image
|
from utils.s3_client import get_image as s3_get_image
|
||||||
from utils.s3_client import upload_image as s3_upload_image
|
from utils.s3_client import upload_image as s3_upload_image
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ from .agents import main_agent
|
|||||||
from .logic import (
|
from .logic import (
|
||||||
add_message_to_conversation,
|
add_message_to_conversation,
|
||||||
get_conversation_by_id,
|
get_conversation_by_id,
|
||||||
rename_conversation,
|
|
||||||
)
|
)
|
||||||
|
from .memory import get_memories_for_user
|
||||||
from .models import (
|
from .models import (
|
||||||
Conversation,
|
Conversation,
|
||||||
PydConversation,
|
PydConversation,
|
||||||
@@ -35,15 +35,27 @@ conversation_blueprint = Blueprint(
|
|||||||
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
|
_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_system_prompt_with_memories(user_id: str) -> str:
|
||||||
|
"""Append user memories to the base system prompt."""
|
||||||
|
memories = await get_memories_for_user(user_id)
|
||||||
|
if not memories:
|
||||||
|
return _SYSTEM_PROMPT
|
||||||
|
memory_block = "\n".join(f"- {m}" for m in memories)
|
||||||
|
return f"{_SYSTEM_PROMPT}\n\nUSER MEMORIES (facts the user has asked you to remember):\n{memory_block}"
|
||||||
|
|
||||||
|
|
||||||
def _build_messages_payload(
|
def _build_messages_payload(
|
||||||
conversation, query_text: str, image_description: str | None = None
|
conversation,
|
||||||
|
query_text: str,
|
||||||
|
image_description: str | None = None,
|
||||||
|
system_prompt: str | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
recent_messages = (
|
recent_messages = (
|
||||||
conversation.messages[-10:]
|
conversation.messages[-10:]
|
||||||
if len(conversation.messages) > 10
|
if len(conversation.messages) > 10
|
||||||
else conversation.messages
|
else conversation.messages
|
||||||
)
|
)
|
||||||
messages_payload = [{"role": "system", "content": _SYSTEM_PROMPT}]
|
messages_payload = [{"role": "system", "content": system_prompt or _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"
|
||||||
text = msg.text
|
text = msg.text
|
||||||
@@ -79,10 +91,14 @@ async def query():
|
|||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
messages_payload = _build_messages_payload(conversation, query)
|
system_prompt = await _build_system_prompt_with_memories(str(user.id))
|
||||||
|
messages_payload = _build_messages_payload(
|
||||||
|
conversation, query, system_prompt=system_prompt
|
||||||
|
)
|
||||||
payload = {"messages": messages_payload}
|
payload = {"messages": messages_payload}
|
||||||
|
agent_config = {"configurable": {"user_id": str(user.id)}}
|
||||||
|
|
||||||
response = await main_agent.ainvoke(payload)
|
response = await main_agent.ainvoke(payload, config=agent_config)
|
||||||
message = response.get("messages", [])[-1].content
|
message = response.get("messages", [])[-1].content
|
||||||
await add_message_to_conversation(
|
await add_message_to_conversation(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
@@ -122,27 +138,14 @@ async def upload_image():
|
|||||||
|
|
||||||
await s3_upload_image(processed_bytes, key, output_content_type)
|
await s3_upload_image(processed_bytes, key, output_content_type)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify({"image_key": key})
|
||||||
{
|
|
||||||
"image_key": key,
|
|
||||||
"image_url": f"/api/conversation/image/{key}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@conversation_blueprint.get("/image/<path:image_key>")
|
@conversation_blueprint.get("/image/<path:image_key>")
|
||||||
@jwt_refresh_token_required
|
@jwt_refresh_token_required
|
||||||
async def serve_image(image_key: str):
|
async def serve_image(image_key: str):
|
||||||
try:
|
url = await s3_presigned_url(image_key)
|
||||||
image_bytes, content_type = await s3_get_image(image_key)
|
return jsonify({"url": url})
|
||||||
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")
|
||||||
@@ -175,15 +178,19 @@ async def stream_query():
|
|||||||
logging.error(f"Failed to analyze image: {e}")
|
logging.error(f"Failed to analyze image: {e}")
|
||||||
image_description = "[Image could not be analyzed]"
|
image_description = "[Image could not be analyzed]"
|
||||||
|
|
||||||
|
system_prompt = await _build_system_prompt_with_memories(str(user.id))
|
||||||
messages_payload = _build_messages_payload(
|
messages_payload = _build_messages_payload(
|
||||||
conversation, query_text or "", image_description
|
conversation, query_text or "", image_description, system_prompt=system_prompt
|
||||||
)
|
)
|
||||||
payload = {"messages": messages_payload}
|
payload = {"messages": messages_payload}
|
||||||
|
agent_config = {"configurable": {"user_id": str(user.id)}}
|
||||||
|
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
final_message = None
|
final_message = None
|
||||||
try:
|
try:
|
||||||
async for event in main_agent.astream_events(payload, version="v2"):
|
async for event in main_agent.astream_events(
|
||||||
|
payload, version="v2", config=agent_config
|
||||||
|
):
|
||||||
event_type = event.get("event")
|
event_type = event.get("event")
|
||||||
|
|
||||||
if event_type == "on_tool_start":
|
if event_type == "on_tool_start":
|
||||||
@@ -233,8 +240,6 @@ async def stream_query():
|
|||||||
@jwt_refresh_token_required
|
@jwt_refresh_token_required
|
||||||
async def get_conversation(conversation_id: str):
|
async def get_conversation(conversation_id: str):
|
||||||
conversation = await Conversation.get(id=conversation_id)
|
conversation = await Conversation.get(id=conversation_id)
|
||||||
current_user_uuid = get_jwt_identity()
|
|
||||||
user = await blueprints.users.models.User.get(id=current_user_uuid)
|
|
||||||
await conversation.fetch_related("messages")
|
await conversation.fetch_related("messages")
|
||||||
|
|
||||||
# Manually serialize the conversation with messages
|
# Manually serialize the conversation with messages
|
||||||
@@ -249,18 +254,10 @@ async def get_conversation(conversation_id: str):
|
|||||||
"image_key": msg.image_key,
|
"image_key": msg.image_key,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
name = conversation.name
|
|
||||||
if len(messages) > 8 and "datetime" in name.lower():
|
|
||||||
name = await rename_conversation(
|
|
||||||
user=user,
|
|
||||||
conversation=conversation,
|
|
||||||
)
|
|
||||||
print(name)
|
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"id": str(conversation.id),
|
"id": str(conversation.id),
|
||||||
"name": name,
|
"name": conversation.name,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"created_at": conversation.created_at.isoformat(),
|
"created_at": conversation.created_at.isoformat(),
|
||||||
"updated_at": conversation.updated_at.isoformat(),
|
"updated_at": conversation.updated_at.isoformat(),
|
||||||
@@ -274,7 +271,7 @@ async def create_conversation():
|
|||||||
user_uuid = get_jwt_identity()
|
user_uuid = get_jwt_identity()
|
||||||
user = await blueprints.users.models.User.get(id=user_uuid)
|
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||||
conversation = await Conversation.create(
|
conversation = await Conversation.create(
|
||||||
name=f"{user.username} {datetime.datetime.now().timestamp}",
|
name="New Conversation",
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -287,7 +284,7 @@ async def create_conversation():
|
|||||||
async def get_all_conversations():
|
async def get_all_conversations():
|
||||||
user_uuid = get_jwt_identity()
|
user_uuid = get_jwt_identity()
|
||||||
user = await blueprints.users.models.User.get(id=user_uuid)
|
user = await blueprints.users.models.User.get(id=user_uuid)
|
||||||
conversations = Conversation.filter(user=user)
|
conversations = Conversation.filter(user=user).order_by("-updated_at")
|
||||||
serialized_conversations = await PydListConversation.from_queryset(conversations)
|
serialized_conversations = await PydListConversation.from_queryset(conversations)
|
||||||
|
|
||||||
return jsonify(serialized_conversations.model_dump())
|
return jsonify(serialized_conversations.model_dump())
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ from dotenv import load_dotenv
|
|||||||
from langchain.agents import create_agent
|
from langchain.agents import create_agent
|
||||||
from langchain.chat_models import BaseChatModel
|
from langchain.chat_models import BaseChatModel
|
||||||
from langchain.tools import tool
|
from langchain.tools import tool
|
||||||
|
from langchain_core.runnables import RunnableConfig
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from tavily import AsyncTavilyClient
|
from tavily import AsyncTavilyClient
|
||||||
|
|
||||||
|
from blueprints.conversation.memory import save_memory
|
||||||
from blueprints.rag.logic import query_vector_store
|
from blueprints.rag.logic import query_vector_store
|
||||||
from utils.obsidian_service import ObsidianService
|
from utils.obsidian_service import ObsidianService
|
||||||
from utils.ynab_service import YNABService
|
from utils.ynab_service import YNABService
|
||||||
@@ -589,8 +591,35 @@ async def obsidian_create_task(
|
|||||||
return f"Error creating task: {str(e)}"
|
return f"Error creating task: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def save_user_memory(content: str, config: RunnableConfig) -> str:
|
||||||
|
"""Save a fact or preference about the user for future conversations.
|
||||||
|
|
||||||
|
Use this tool when the user:
|
||||||
|
- Explicitly asks you to remember something ("remember that...", "keep in mind...")
|
||||||
|
- Shares a personal preference that would be useful in future conversations
|
||||||
|
(e.g., "I prefer metric units", "my cat's name is Luna")
|
||||||
|
- Tells you a meaningful personal fact (e.g., "I'm allergic to peanuts")
|
||||||
|
|
||||||
|
Do NOT save:
|
||||||
|
- Trivial or ephemeral info (e.g., "I'm tired today")
|
||||||
|
- Information already in the system prompt or documents
|
||||||
|
- Conversation-specific context that won't matter later
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: A concise statement of the fact or preference to remember.
|
||||||
|
Write it as a standalone sentence (e.g., "User prefers dark mode"
|
||||||
|
rather than "likes dark mode").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation that the memory was saved.
|
||||||
|
"""
|
||||||
|
user_id = config["configurable"]["user_id"]
|
||||||
|
return await save_memory(user_id=user_id, content=content)
|
||||||
|
|
||||||
|
|
||||||
# Create tools list based on what's available
|
# Create tools list based on what's available
|
||||||
tools = [get_current_date, simba_search, web_search]
|
tools = [get_current_date, simba_search, web_search, save_user_memory]
|
||||||
if ynab_enabled:
|
if ynab_enabled:
|
||||||
tools.extend(
|
tools.extend(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import tortoise.exceptions
|
import tortoise.exceptions
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
|
|
||||||
import blueprints.users.models
|
import blueprints.users.models
|
||||||
|
|
||||||
from .models import Conversation, ConversationMessage, RenameConversationOutputSchema
|
from .models import Conversation, ConversationMessage
|
||||||
|
|
||||||
|
|
||||||
async def create_conversation(name: str = "") -> Conversation:
|
async def create_conversation(name: str = "") -> Conversation:
|
||||||
@@ -19,6 +18,12 @@ async def add_message_to_conversation(
|
|||||||
image_key: str | None = None,
|
image_key: str | None = None,
|
||||||
) -> ConversationMessage:
|
) -> ConversationMessage:
|
||||||
print(conversation, message, speaker)
|
print(conversation, message, speaker)
|
||||||
|
|
||||||
|
# Name the conversation after the first user message
|
||||||
|
if speaker == "user" and not await conversation.messages.all().exists():
|
||||||
|
conversation.name = message[:100]
|
||||||
|
await conversation.save()
|
||||||
|
|
||||||
message = await ConversationMessage.create(
|
message = await ConversationMessage.create(
|
||||||
text=message,
|
text=message,
|
||||||
speaker=speaker,
|
speaker=speaker,
|
||||||
@@ -61,22 +66,3 @@ async def get_conversation_transcript(
|
|||||||
messages.append(f"{message.speaker} at {message.created_at}: {message.text}")
|
messages.append(f"{message.speaker} at {message.created_at}: {message.text}")
|
||||||
|
|
||||||
return "\n".join(messages)
|
return "\n".join(messages)
|
||||||
|
|
||||||
|
|
||||||
async def rename_conversation(
|
|
||||||
user: blueprints.users.models.User,
|
|
||||||
conversation: Conversation,
|
|
||||||
) -> str:
|
|
||||||
messages: str = await get_conversation_transcript(
|
|
||||||
user=user, conversation=conversation
|
|
||||||
)
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="gpt-4o-mini")
|
|
||||||
structured_llm = llm.with_structured_output(RenameConversationOutputSchema)
|
|
||||||
|
|
||||||
prompt = f"Summarize the following conversation into a sassy one-liner title:\n\n{messages}"
|
|
||||||
response = structured_llm.invoke(prompt)
|
|
||||||
new_name: str = response.get("title", "")
|
|
||||||
conversation.name = new_name
|
|
||||||
await conversation.save()
|
|
||||||
return new_name
|
|
||||||
|
|||||||
19
blueprints/conversation/memory.py
Normal file
19
blueprints/conversation/memory.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from .models import UserMemory
|
||||||
|
|
||||||
|
|
||||||
|
async def get_memories_for_user(user_id: str) -> list[str]:
|
||||||
|
"""Return all memory content strings for a user, ordered by most recently updated."""
|
||||||
|
memories = await UserMemory.filter(user_id=user_id).order_by("-updated_at")
|
||||||
|
return [m.content for m in memories]
|
||||||
|
|
||||||
|
|
||||||
|
async def save_memory(user_id: str, content: str) -> str:
|
||||||
|
"""Save a new memory or touch an existing one (exact-match dedup)."""
|
||||||
|
existing = await UserMemory.filter(user_id=user_id, content=content).first()
|
||||||
|
if existing:
|
||||||
|
existing.updated_at = None # auto_now=True will refresh it on save
|
||||||
|
await existing.save(update_fields=["updated_at"])
|
||||||
|
return "Memory already exists (refreshed)."
|
||||||
|
|
||||||
|
await UserMemory.create(user_id=user_id, content=content)
|
||||||
|
return "Memory saved."
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import enum
|
import enum
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.contrib.pydantic import (
|
from tortoise.contrib.pydantic import (
|
||||||
@@ -9,12 +8,6 @@ from tortoise.contrib.pydantic import (
|
|||||||
from tortoise.models import Model
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RenameConversationOutputSchema:
|
|
||||||
title: str
|
|
||||||
justification: str
|
|
||||||
|
|
||||||
|
|
||||||
class Speaker(enum.Enum):
|
class Speaker(enum.Enum):
|
||||||
USER = "user"
|
USER = "user"
|
||||||
SIMBA = "simba"
|
SIMBA = "simba"
|
||||||
@@ -47,6 +40,17 @@ class ConversationMessage(Model):
|
|||||||
table = "conversation_messages"
|
table = "conversation_messages"
|
||||||
|
|
||||||
|
|
||||||
|
class UserMemory(Model):
|
||||||
|
id = fields.UUIDField(primary_key=True)
|
||||||
|
user = fields.ForeignKeyField("models.User", related_name="memories")
|
||||||
|
content = fields.TextField()
|
||||||
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "user_memories"
|
||||||
|
|
||||||
|
|
||||||
PydConversationMessage = pydantic_model_creator(ConversationMessage)
|
PydConversationMessage = pydantic_model_creator(ConversationMessage)
|
||||||
PydConversation = pydantic_model_creator(
|
PydConversation = pydantic_model_creator(
|
||||||
Conversation, name="Conversation", allow_cycles=True, exclude=("user",)
|
Conversation, name="Conversation", allow_cycles=True, exclude=("user",)
|
||||||
|
|||||||
@@ -54,4 +54,7 @@ You have access to Ryan's daily journal notes. Each note lives at journal/YYYY/Y
|
|||||||
- Use journal_get_tasks to list tasks (done/pending) for today or a specific date
|
- 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_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 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."""
|
Use these tools when Ryan asks about today's tasks, wants to add something to his list, or wants to mark a task complete.
|
||||||
|
|
||||||
|
USER MEMORY:
|
||||||
|
You can remember facts about the user across conversations using the save_user_memory tool. When a user explicitly asks you to remember something, or shares a meaningful preference or personal fact, save it. Saved memories will automatically appear at the end of this prompt in future conversations under "USER MEMORIES"."""
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class OIDCUserService:
|
|||||||
claims.get("preferred_username") or claims.get("name") or user.username
|
claims.get("preferred_username") or claims.get("name") or user.username
|
||||||
)
|
)
|
||||||
# Update LDAP groups from claims
|
# Update LDAP groups from claims
|
||||||
user.ldap_groups = claims.get("groups", [])
|
user.ldap_groups = claims.get("groups") or []
|
||||||
await user.save()
|
await user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class OIDCUserService:
|
|||||||
user.oidc_subject = oidc_subject
|
user.oidc_subject = oidc_subject
|
||||||
user.auth_provider = "oidc"
|
user.auth_provider = "oidc"
|
||||||
user.password = None # Clear password
|
user.password = None # Clear password
|
||||||
user.ldap_groups = claims.get("groups", [])
|
user.ldap_groups = claims.get("groups") or []
|
||||||
await user.save()
|
await user.save()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class OIDCUserService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Extract LDAP groups from claims
|
# Extract LDAP groups from claims
|
||||||
groups = claims.get("groups", [])
|
groups = claims.get("groups") or []
|
||||||
|
|
||||||
user = await User.create(
|
user = await User.create(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
|
|||||||
112
migrations/models/5_20260409215154_add_user_memories.py
Normal file
112
migrations/models/5_20260409215154_add_user_memories.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS "user_memories" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "email_accounts" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"email_address" VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
"display_name" VARCHAR(255),
|
||||||
|
"imap_host" VARCHAR(255) NOT NULL,
|
||||||
|
"imap_port" INT NOT NULL DEFAULT 993,
|
||||||
|
"imap_username" VARCHAR(255) NOT NULL,
|
||||||
|
"imap_password" TEXT NOT NULL,
|
||||||
|
"is_active" BOOL NOT NULL DEFAULT True,
|
||||||
|
"last_error" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE "email_accounts" IS 'Email account configuration for IMAP connections.';
|
||||||
|
CREATE TABLE IF NOT EXISTS "emails" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"message_id" VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
"subject" VARCHAR(500) NOT NULL,
|
||||||
|
"from_address" VARCHAR(255) NOT NULL,
|
||||||
|
"to_address" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMPTZ NOT NULL,
|
||||||
|
"body_text" TEXT,
|
||||||
|
"body_html" TEXT,
|
||||||
|
"chromadb_doc_id" VARCHAR(255),
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
"account_id" UUID NOT NULL REFERENCES "email_accounts" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_emails_message_981ddd" ON "emails" ("message_id");
|
||||||
|
COMMENT ON TABLE "emails" IS 'Email message metadata and content.';
|
||||||
|
CREATE TABLE IF NOT EXISTS "email_sync_status" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"last_sync_date" TIMESTAMPTZ,
|
||||||
|
"last_message_uid" INT NOT NULL DEFAULT 0,
|
||||||
|
"message_count" INT NOT NULL DEFAULT 0,
|
||||||
|
"consecutive_failures" INT NOT NULL DEFAULT 0,
|
||||||
|
"last_failure_date" TIMESTAMPTZ,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"account_id" UUID NOT NULL REFERENCES "email_accounts" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE "email_sync_status" IS 'Tracks sync progress and state per email account.';"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
DROP TABLE IF EXISTS "user_memories";
|
||||||
|
DROP TABLE IF EXISTS "email_accounts";
|
||||||
|
DROP TABLE IF EXISTS "emails";
|
||||||
|
DROP TABLE IF EXISTS "email_sync_status";"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"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"
|
||||||
|
)
|
||||||
@@ -125,7 +125,7 @@ class ConversationService {
|
|||||||
async uploadImage(
|
async uploadImage(
|
||||||
file: File,
|
file: File,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
): Promise<{ image_key: string; image_url: string }> {
|
): Promise<{ image_key: string }> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("conversation_id", conversationId);
|
formData.append("conversation_id", conversationId);
|
||||||
@@ -147,8 +147,15 @@ class ConversationService {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
getImageUrl(imageKey: string): string {
|
async getPresignedImageUrl(imageKey: string): Promise<string> {
|
||||||
return `/api/conversation/image/${imageKey}`;
|
const response = await userService.fetchWithRefreshToken(
|
||||||
|
`${this.conversationBaseUrl}/image/${imageKey}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to get image URL");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
return data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async streamQuery(
|
async streamQuery(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useCallback, useEffect, useState, useRef } from "react";
|
||||||
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-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 { userService } from "../api/userService";
|
||||||
@@ -63,9 +63,13 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = useCallback(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
requestAnimationFrame(() => {
|
||||||
};
|
messagesEndRef.current?.scrollIntoView({
|
||||||
|
behavior: isLoading ? "instant" : "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
@@ -116,21 +120,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleQuestionSubmit = useCallback(async () => {
|
||||||
const load = async () => {
|
|
||||||
if (!selectedConversation) return;
|
|
||||||
try {
|
|
||||||
const conv = await conversationService.getConversation(selectedConversation.id);
|
|
||||||
setSelectedConversation({ id: conv.id, title: conv.name });
|
|
||||||
setMessages(conv.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load messages:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
load();
|
|
||||||
}, [selectedConversation?.id]);
|
|
||||||
|
|
||||||
const handleQuestionSubmit = async () => {
|
|
||||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
if ((!query.trim() && !pendingImage) || isLoading) return;
|
||||||
|
|
||||||
let activeConversation = selectedConversation;
|
let activeConversation = selectedConversation;
|
||||||
@@ -211,22 +201,28 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) setIsLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
loadConversations();
|
||||||
|
}
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
}, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]);
|
||||||
|
|
||||||
const handleQueryChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setQuery(event.target.value);
|
setQuery(event.target.value);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
||||||
if (kev.key === "Enter" && !kev.shiftKey) {
|
if (kev.key === "Enter" && !kev.shiftKey) {
|
||||||
kev.preventDefault();
|
kev.preventDefault();
|
||||||
handleQuestionSubmit();
|
handleQuestionSubmit();
|
||||||
}
|
}
|
||||||
};
|
}, [handleQuestionSubmit]);
|
||||||
|
|
||||||
|
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []);
|
||||||
|
const handleClearImage = useCallback(() => setPendingImage(null), []);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("access_token");
|
localStorage.removeItem("access_token");
|
||||||
@@ -380,8 +376,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
setSimbaMode={setSimbaMode}
|
setSimbaMode={setSimbaMode}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
pendingImage={pendingImage}
|
pendingImage={pendingImage}
|
||||||
onImageSelect={(file) => setPendingImage(file)}
|
onImageSelect={handleImageSelect}
|
||||||
onClearImage={() => setPendingImage(null)}
|
onClearImage={handleClearImage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,7 +412,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="border-t border-sand-light/40 bg-cream/80 backdrop-blur-sm">
|
<footer className="border-t border-sand-light/40 bg-cream">
|
||||||
<div className="max-w-2xl mx-auto px-4 py-3">
|
<div className="max-w-2xl mx-auto px-4 py-3">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
query={query}
|
query={query}
|
||||||
@@ -425,6 +421,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
handleQuestionSubmit={handleQuestionSubmit}
|
handleQuestionSubmit={handleQuestionSubmit}
|
||||||
setSimbaMode={setSimbaMode}
|
setSimbaMode={setSimbaMode}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
pendingImage={pendingImage}
|
||||||
|
onImageSelect={(file) => setPendingImage(file)}
|
||||||
|
onClearImage={() => setPendingImage(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ArrowUp, ImagePlus, X } from "lucide-react";
|
import { ArrowUp, ImagePlus, X } from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Textarea } from "./ui/textarea";
|
import { Textarea } from "./ui/textarea";
|
||||||
@@ -15,7 +15,7 @@ type MessageInputProps = {
|
|||||||
onClearImage: () => void;
|
onClearImage: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MessageInput = ({
|
export const MessageInput = React.memo(({
|
||||||
query,
|
query,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleQueryChange,
|
handleQueryChange,
|
||||||
@@ -29,6 +29,18 @@ export const MessageInput = ({
|
|||||||
const [simbaMode, setLocalSimbaMode] = useState(false);
|
const [simbaMode, setLocalSimbaMode] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Create blob URL once per file, revoke on cleanup
|
||||||
|
const previewUrl = useMemo(
|
||||||
|
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
||||||
|
[pendingImage],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
const toggleSimbaMode = () => {
|
const toggleSimbaMode = () => {
|
||||||
const next = !simbaMode;
|
const next = !simbaMode;
|
||||||
setLocalSimbaMode(next);
|
setLocalSimbaMode(next);
|
||||||
@@ -59,7 +71,7 @@ export const MessageInput = ({
|
|||||||
<div className="px-3 pt-3">
|
<div className="px-3 pt-3">
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(pendingImage)}
|
src={previewUrl!}
|
||||||
alt="Pending upload"
|
alt="Pending upload"
|
||||||
className="h-20 rounded-lg object-cover border border-sand"
|
className="h-20 rounded-lg object-cover border border-sand"
|
||||||
/>
|
/>
|
||||||
@@ -145,4 +157,4 @@ export const MessageInput = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { conversationService } from "../api/conversationService";
|
import { conversationService } from "../api/conversationService";
|
||||||
|
|
||||||
@@ -7,6 +8,20 @@ type QuestionBubbleProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!image_key) return;
|
||||||
|
conversationService
|
||||||
|
.getPresignedImageUrl(image_key)
|
||||||
|
.then(setImageUrl)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to load image:", err);
|
||||||
|
setImageError(true);
|
||||||
|
});
|
||||||
|
}, [image_key]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end message-enter">
|
<div className="flex justify-end message-enter">
|
||||||
<div
|
<div
|
||||||
@@ -17,9 +32,15 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
|||||||
"shadow-sm shadow-leaf/10",
|
"shadow-sm shadow-leaf/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{image_key && (
|
{imageError && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2">
|
||||||
|
<span>🖼️</span>
|
||||||
|
<span>Image failed to load</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{imageUrl && (
|
||||||
<img
|
<img
|
||||||
src={conversationService.getImageUrl(image_key)}
|
src={imageUrl}
|
||||||
alt="Uploaded image"
|
alt="Uploaded image"
|
||||||
className="max-w-full rounded-xl mb-2"
|
className="max-w-full rounded-xl mb-2"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ async def get_image(key: str) -> tuple[bytes, str]:
|
|||||||
return body, content_type
|
return body, content_type
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_presigned_url(key: str, expires_in: int = 3600) -> str:
|
||||||
|
async with _get_client() as client:
|
||||||
|
url = await client.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": S3_BUCKET_NAME, "Key": key},
|
||||||
|
ExpiresIn=expires_in,
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
async def delete_image(key: str) -> None:
|
async def delete_image(key: str) -> None:
|
||||||
async with _get_client() as client:
|
async with _get_client() as client:
|
||||||
await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)
|
await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)
|
||||||
|
|||||||
Reference in New Issue
Block a user