19 Commits

Author SHA1 Message Date
ryan
289045e7d0 Merge pull request 'mobile-responsive-layout' (#9) from mobile-responsive-layout into main
Reviewed-on: #9
2025-10-29 21:15:14 -04:00
ryan
ceea83cb54 Merge branch 'main' into mobile-responsive-layout 2025-10-29 21:15:10 -04:00
Ryan Chen
1b60aab97c sdf 2025-10-29 21:14:52 -04:00
ryan
210bfc1476 Merge pull request 'query classification' (#8) from async-reindexing into main
Reviewed-on: #8
2025-10-29 21:13:42 -04:00
Ryan Chen
454fb1b52c Add authentication validation on login screen load
- Add validateToken() method to userService to check if refresh token is valid
- Automatically redirect to chat if user already has valid session
- Show 'Checking authentication...' loading state during validation
- Prevents unnecessary login if user is already authenticated
- Improves UX by skipping login screen when not needed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:24:10 -04:00
Ryan Chen
c3f2501585 Clear text input immediately upon message submission
- Clear input field right after user sends message (before API call)
- Add validation to prevent submitting empty/whitespace-only messages
- Improve UX by allowing user to type next message while waiting for response
- Works for both simba mode and normal mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:22:32 -04:00
Ryan Chen
1da21fabee Add auto-scroll to bottom for new messages
- Automatically scroll to latest message when new messages arrive
- Uses smooth scrolling behavior for better UX
- Triggers on message array changes
- Improves chat experience by keeping conversation in view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:12:05 -04:00
Ryan Chen
dd5690ee53 Add submit on Enter for chat textarea
- Press Enter to submit message
- Press Shift+Enter to insert new line
- Add helpful placeholder text explaining keyboard shortcuts
- Improve chat UX with standard messaging behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:07:47 -04:00
Ryan Chen
5e7ac28b6f Update add_user.py to use configurable database path
- Use DATABASE_PATH and DATABASE_URL environment variables
- Consistent with app.py and aerich_config.py configuration
- Add environment variable documentation to help text
- Default remains database/raggr.db for backward compatibility

Usage:
  DATABASE_PATH=dev.db python add_user.py list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:02:56 -04:00
Ryan Chen
29f8894e4a Add configurable database path via environment variables
- Add DATABASE_PATH environment variable support in app.py and aerich_config.py
- DATABASE_PATH: For simple relative/absolute paths (default: database/raggr.db)
- DATABASE_URL: For full connection strings (overrides DATABASE_PATH if set)
- Create .env.example with all configuration options documented
- Maintains backward compatibility with default database location

Usage:
  # Use default path
  python app.py

  # Use custom path for development
  DATABASE_PATH=dev.db python app.py

  # Use full connection string
  DATABASE_URL=sqlite://custom/path.db python app.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 12:01:16 -04:00
Ryan Chen
19d1df2f68 Improve mobile responsiveness with Tailwind breakpoints
- Replace fixed-width containers (min-w-xl max-w-xl) with responsive classes
- Mobile: full width with padding, Tablet: 90% max 768px, Desktop: max 1024px
- Make ChatScreen header stack vertically on mobile, horizontal on desktop
- Add touch-friendly button sizes (min 44x44px tap targets)
- Optimize textarea and form inputs for mobile keyboards
- Add text wrapping (break-words) to message bubbles to prevent overflow
- Apply responsive text sizing (text-sm sm:text-base) throughout
- Improve ConversationList with touch-friendly hit areas
- Add responsive padding/spacing across all components

All components now use standard Tailwind breakpoints:
- sm: 640px+ (tablet)
- md: 768px+ (larger tablet)
- lg: 1024px+ (desktop)
- xl: 1280px+ (large desktop)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 11:57:54 -04:00
Ryan Chen
e577cb335b query classification 2025-10-26 17:29:00 -04:00
Ryan Chen
591788dfa4 reindex pls 2025-10-26 11:06:32 -04:00
Ryan Chen
561b5bddce reindex pls 2025-10-26 11:04:33 -04:00
Ryan Chen
ddd455a4c6 reindex pls 2025-10-26 11:02:51 -04:00
ryan
07424e77e0 Merge pull request 'favicon' (#7) from update-favicon-and-title into main
Reviewed-on: #7
2025-10-26 10:49:27 -04:00
Ryan Chen
a56f752917 favicon 2025-10-26 10:48:59 -04:00
Ryan Chen
e8264e80ce Changing DB thing 2025-10-26 09:36:33 -04:00
ryan
04350045d3 Merge pull request 'Adding support for conversations and multiple threads' (#6) from conversation-uplift into main
Reviewed-on: #6
2025-10-26 09:25:52 -04:00
17 changed files with 322 additions and 78 deletions

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
# Database Configuration
# Use DATABASE_PATH for simple relative/absolute paths (e.g., "database/raggr.db" or "dev.db")
# Or use DATABASE_URL for full connection strings (e.g., "sqlite://database/raggr.db")
DATABASE_PATH=database/raggr.db
# DATABASE_URL=sqlite://database/raggr.db
# JWT Configuration
JWT_SECRET_KEY=your-secret-key-here
# Paperless Configuration
PAPERLESS_TOKEN=your-paperless-token
BASE_URL=192.168.1.5:8000
# Ollama Configuration
OLLAMA_URL=http://192.168.1.14:11434
OLLAMA_HOST=http://192.168.1.14:11434
# ChromaDB Configuration
CHROMADB_PATH=/path/to/chromadb
# OpenAI Configuration
OPENAI_API_KEY=your-openai-api-key
# Immich Configuration
IMMICH_URL=http://192.168.1.5:2283
IMMICH_API_KEY=your-immich-api-key
SEARCH_QUERY=simba cat
DOWNLOAD_DIR=./simba_photos

View File

@@ -24,7 +24,6 @@ RUN uv pip install --system -e .
# Copy application code # Copy application code
COPY *.py ./ COPY *.py ./
COPY blueprints ./blueprints COPY blueprints ./blueprints
COPY aerich.toml ./
COPY migrations ./migrations COPY migrations ./migrations
COPY startup.sh ./ COPY startup.sh ./
RUN chmod +x startup.sh RUN chmod +x startup.sh
@@ -35,8 +34,8 @@ WORKDIR /app/raggr-frontend
RUN yarn install && yarn build RUN yarn install && yarn build
WORKDIR /app WORKDIR /app
# Create ChromaDB directory # Create ChromaDB and database directories
RUN mkdir -p /app/chromadb RUN mkdir -p /app/chromadb /app/database
# Expose port # Expose port
EXPOSE 8080 EXPOSE 8080

View File

@@ -1,16 +1,27 @@
# GENERATED BY CLAUDE # GENERATED BY CLAUDE
import os
import sys import sys
import uuid import uuid
import asyncio import asyncio
from tortoise import Tortoise from tortoise import Tortoise
from blueprints.users.models import User from blueprints.users.models import User
from dotenv import load_dotenv
load_dotenv()
# Database configuration with environment variable support
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite://{DATABASE_PATH}")
print(DATABASE_URL)
async def add_user(username: str, email: str, password: str): async def add_user(username: str, email: str, password: str):
"""Add a new user to the database""" """Add a new user to the database"""
await Tortoise.init( await Tortoise.init(
db_url="sqlite://raggr.db", db_url=DATABASE_URL,
modules={ modules={
"models": [ "models": [
"blueprints.users.models", "blueprints.users.models",
@@ -56,7 +67,7 @@ async def add_user(username: str, email: str, password: str):
async def list_users(): async def list_users():
"""List all users in the database""" """List all users in the database"""
await Tortoise.init( await Tortoise.init(
db_url="sqlite://raggr.db", db_url=DATABASE_URL,
modules={ modules={
"models": [ "models": [
"blueprints.users.models", "blueprints.users.models",
@@ -94,6 +105,11 @@ def print_usage():
print("\nExamples:") print("\nExamples:")
print(" python add_user.py add ryan ryan@example.com mypassword123") print(" python add_user.py add ryan ryan@example.com mypassword123")
print(" python add_user.py list") print(" python add_user.py list")
print("\nEnvironment Variables:")
print(" DATABASE_PATH - Path to database file (default: database/raggr.db)")
print(" DATABASE_URL - Full database URL (overrides DATABASE_PATH)")
print("\n Example with custom database:")
print(" DATABASE_PATH=dev.db python add_user.py list")
async def main(): async def main():

View File

@@ -1,7 +1,12 @@
import os import os
# Database configuration with environment variable support
# Use DATABASE_PATH for relative paths or DATABASE_URL for full connection strings
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite://{DATABASE_PATH}")
TORTOISE_ORM = { TORTOISE_ORM = {
"connections": {"default": os.getenv("DATABASE_URL", "sqlite:///app/raggr.db")}, "connections": {"default": DATABASE_URL},
"apps": { "apps": {
"models": { "models": {
"models": [ "models": [

5
app.py
View File

@@ -26,8 +26,11 @@ app.register_blueprint(blueprints.users.user_blueprint)
app.register_blueprint(blueprints.conversation.conversation_blueprint) app.register_blueprint(blueprints.conversation.conversation_blueprint)
# Database configuration with environment variable support
DATABASE_PATH = os.getenv("DATABASE_PATH", "database/raggr.db")
TORTOISE_CONFIG = { TORTOISE_CONFIG = {
"connections": {"default": "sqlite://raggr.db"}, "connections": {"default": f"sqlite://{DATABASE_PATH}"},
"apps": { "apps": {
"models": { "models": {
"models": [ "models": [

View File

@@ -12,6 +12,8 @@ services:
- OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY}
volumes: volumes:
- chromadb_data:/app/chromadb - chromadb_data:/app/chromadb
- database_data:/app/database
volumes: volumes:
chromadb_data: chromadb_data:
database_data:

View File

@@ -27,7 +27,7 @@ headers = {"x-api-key": API_KEY, "Content-Type": "application/json"}
VISITED = {} VISITED = {}
if __name__ == "__main__": if __name__ == "__main__":
conn = sqlite3.connect("./visited.db") conn = sqlite3.connect("./database/visited.db")
c = conn.cursor() c = conn.cursor()
c.execute("select immich_id from visited") c.execute("select immich_id from visited")
rows = c.fetchall() rows = c.fetchall()

111
main.py
View File

@@ -7,6 +7,8 @@ import argparse
import chromadb import chromadb
import ollama import ollama
import time
from request import PaperlessNGXService from request import PaperlessNGXService
from chunker import Chunker from chunker import Chunker
@@ -36,6 +38,7 @@ parser.add_argument("query", type=str, help="questions about simba's health")
parser.add_argument( parser.add_argument(
"--reindex", action="store_true", help="re-index the simba documents" "--reindex", action="store_true", help="re-index the simba documents"
) )
parser.add_argument("--classify", action="store_true", help="test classification")
parser.add_argument("--index", help="index a file") parser.add_argument("--index", help="index a file")
ppngx = PaperlessNGXService() ppngx = PaperlessNGXService()
@@ -77,7 +80,7 @@ def chunk_data(docs, collection, doctypes):
logging.info(f"chunking {len(docs)} documents") logging.info(f"chunking {len(docs)} documents")
texts: list[str] = [doc["content"] for doc in docs] texts: list[str] = [doc["content"] for doc in docs]
with sqlite3.connect("visited.db") as conn: with sqlite3.connect("database/visited.db") as conn:
to_insert = [] to_insert = []
c = conn.cursor() c = conn.cursor()
for index, text in enumerate(texts): for index, text in enumerate(texts):
@@ -113,13 +116,22 @@ def chunk_text(texts: list[str], collection):
) )
def classify_query(query: str, transcript: str) -> bool:
logging.info("Starting query generation")
qg_start = time.time()
qg = QueryGenerator()
query_type = qg.get_query_type(input=query, transcript=transcript)
logging.info(query_type)
qg_end = time.time()
logging.info(f"Query generation took {qg_end - qg_start:.2f} seconds")
return query_type == "Simba"
def consult_oracle( def consult_oracle(
input: str, input: str,
collection, collection,
transcript: str = "", transcript: str = "",
): ):
import time
chunker = Chunker(collection) chunker = Chunker(collection)
start_time = time.time() start_time = time.time()
@@ -171,6 +183,16 @@ def consult_oracle(
return output return output
def llm_chat(input: str, transcript: str = "") -> str:
system_prompt = "You are a helpful assistant that understands veterinary terms."
transcript_prompt = f"Here is the message transcript thus far {transcript}."
prompt = f"""Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
{transcript_prompt if len(transcript) > 0 else ""}
Respond to this prompt: {input}"""
output = llm_client.chat(prompt=prompt, system_prompt=system_prompt)
return output
def paperless_workflow(input): def paperless_workflow(input):
# Step 1: Get the text # Step 1: Get the text
ppngx = PaperlessNGXService() ppngx = PaperlessNGXService()
@@ -181,15 +203,23 @@ def paperless_workflow(input):
def consult_simba_oracle(input: str, transcript: str = ""): def consult_simba_oracle(input: str, transcript: str = ""):
return consult_oracle( is_simba_related = classify_query(query=input, transcript=transcript)
input=input,
collection=simba_docs, if is_simba_related:
transcript=transcript, logging.info("Query is related to simba")
) return consult_oracle(
input=input,
collection=simba_docs,
transcript=transcript,
)
logging.info("Query is NOT related to simba")
return llm_chat(input=input, transcript=transcript)
def filter_indexed_files(docs): def filter_indexed_files(docs):
with sqlite3.connect("visited.db") as conn: with sqlite3.connect("database/visited.db") as conn:
c = conn.cursor() c = conn.cursor()
c.execute( c.execute(
"CREATE TABLE IF NOT EXISTS indexed_documents (id INTEGER PRIMARY KEY AUTOINCREMENT, paperless_id INTEGER)" "CREATE TABLE IF NOT EXISTS indexed_documents (id INTEGER PRIMARY KEY AUTOINCREMENT, paperless_id INTEGER)"
@@ -202,38 +232,45 @@ def filter_indexed_files(docs):
return [doc for doc in docs if doc["id"] not in visited] return [doc for doc in docs if doc["id"] not in visited]
def reindex():
with sqlite3.connect("database/visited.db") as conn:
c = conn.cursor()
c.execute("DELETE FROM indexed_documents")
conn.commit()
# Delete all documents from the collection
all_docs = simba_docs.get()
if all_docs["ids"]:
simba_docs.delete(ids=all_docs["ids"])
logging.info("Fetching documents from Paperless-NGX")
ppngx = PaperlessNGXService()
docs = ppngx.get_data()
docs = filter_indexed_files(docs)
logging.info(f"Fetched {len(docs)} documents")
# Delete all chromadb data
ids = simba_docs.get(ids=None, limit=None, offset=0)
all_ids = ids["ids"]
if len(all_ids) > 0:
simba_docs.delete(ids=all_ids)
# Chunk documents
logging.info("Chunking documents now ...")
doctype_lookup = ppngx.get_doctypes()
chunk_data(docs, collection=simba_docs, doctypes=doctype_lookup)
logging.info("Done chunking documents")
if __name__ == "__main__": if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
if args.reindex: if args.reindex:
logging.info("Fetching documents from Paperless-NGX") reindex()
ppngx = PaperlessNGXService()
docs = ppngx.get_data()
docs = filter_indexed_files(docs)
logging.info(f"Fetched {len(docs)} documents")
# Delete all chromadb data if args.classify:
ids = simba_docs.get(ids=None, limit=None, offset=0) consult_simba_oracle(input="yohohoho testing")
all_ids = ids["ids"] consult_simba_oracle(input="write an email")
if len(all_ids) > 0: consult_simba_oracle(input="how much does simba weigh")
simba_docs.delete(ids=all_ids)
# Chunk documents
logging.info("Chunking documents now ...")
tag_lookup = ppngx.get_tags()
doctype_lookup = ppngx.get_doctypes()
chunk_data(docs, collection=simba_docs, doctypes=doctype_lookup)
logging.info("Done chunking documents")
# if args.index:
# with open(args.index) as file:
# extension = args.index.split(".")[-1]
# if extension == "pdf":
# pdf_path = ppngx.download_pdf_from_id(id=document_id)
# image_paths = pdf_to_image(filepath=pdf_path)
# print(f"summarizing {file}")
# generated_summary = summarize_pdf_image(filepaths=image_paths)
# elif extension in [".md", ".txt"]:
# chunk_text(texts=[file.readall()], collection=simba_docs)
if args.query: if args.query:
logging.info("Consulting oracle ...") logging.info("Consulting oracle ...")

View File

@@ -49,11 +49,20 @@ DOCTYPE_OPTIONS = [
"Letter", "Letter",
] ]
QUERY_TYPE_OPTIONS = [
"Simba",
"Other",
]
class DocumentType(BaseModel): class DocumentType(BaseModel):
type: list[str] = Field(description="type of document", enum=DOCTYPE_OPTIONS) type: list[str] = Field(description="type of document", enum=DOCTYPE_OPTIONS)
class QueryType(BaseModel):
type: str = Field(desciption="type of query", enum=QUERY_TYPE_OPTIONS)
PROMPT = """ PROMPT = """
You are an information specialist that processes user queries. The current year is 2025. The user queries are all about You are an information specialist that processes user queries. The current year is 2025. The user queries are all about
a cat, Simba, and its records. The types of records are listed below. Using the query, extract the a cat, Simba, and its records. The types of records are listed below. Using the query, extract the
@@ -111,6 +120,27 @@ Query: "Who does Simba know?"
Tags: ["Letter", "Documentation"] Tags: ["Letter", "Documentation"]
""" """
QUERY_TYPE_PROMPT = f"""You are an information specialist that processes user queries.
A query can have one tag attached from the following options. Based on the query and the transcript which is listed below, determine
which of the following options is most appropriate: {",".join(QUERY_TYPE_OPTIONS)}
### Example 1
Query: "Who is Simba's current vet?"
Tags: ["Simba"]
### Example 2
Query: "What is the capital of Tokyo?"
Tags: ["Other"]
### Example 3
Query: "Can you help me write an email?"
Tags: ["Other"]
TRANSCRIPT:
"""
class QueryGenerator: class QueryGenerator:
def __init__(self) -> None: def __init__(self) -> None:
@@ -154,6 +184,33 @@ class QueryGenerator:
metadata_query = {"document_type": {"$in": type_data["type"]}} metadata_query = {"document_type": {"$in": type_data["type"]}}
return metadata_query return metadata_query
def get_query_type(self, input: str, transcript: str):
client = OpenAI()
response = client.chat.completions.create(
messages=[
{
"role": "system",
"content": "You are an information specialist that is really good at deciding what tags a query should have",
},
{
"role": "user",
"content": f"{QUERY_TYPE_PROMPT}\nTRANSCRIPT:\n{transcript}\nQUERY:{input}",
},
],
model="gpt-4o",
response_format={
"type": "json_schema",
"json_schema": {
"name": "query_type",
"schema": QueryType.model_json_schema(),
},
},
)
response_json_str = response.choices[0].message.content
type_data = json.loads(response_json_str)
return type_data["type"]
def get_query(self, input: str): def get_query(self, input: str):
client = OpenAI() client = OpenAI()
response = client.responses.parse( response = client.responses.parse(

View File

@@ -3,4 +3,8 @@ import { pluginReact } from '@rsbuild/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [pluginReact()], plugins: [pluginReact()],
html: {
title: 'Raggr',
favicon: './src/assets/favicon.svg',
},
}); });

View File

@@ -55,6 +55,21 @@ class UserService {
return data.access_token; return data.access_token;
} }
async validateToken(): Promise<boolean> {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) {
return false;
}
try {
await this.refreshToken();
return true;
} catch (error) {
return false;
}
}
async fetchWithAuth( async fetchWithAuth(
url: string, url: string,
options: RequestInit = {}, options: RequestInit = {},

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="80" font-size="80" font-family="system-ui, -apple-system, sans-serif">🐱</text>
</svg>

After

Width:  |  Height:  |  Size: 163 B

View File

@@ -7,7 +7,7 @@ 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"> <div className="rounded-md bg-orange-100 p-3 sm:p-4">
{loading ? ( {loading ? (
<div className="flex flex-col w-full animate-pulse gap-2"> <div className="flex flex-col w-full animate-pulse gap-2">
<div className="flex flex-row gap-2 w-full"> <div className="flex flex-row gap-2 w-full">
@@ -20,8 +20,10 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col break-words overflow-wrap-anywhere">
<ReactMarkdown>{"🐈: " + text}</ReactMarkdown> <ReactMarkdown className="text-sm sm:text-base [&>*]:break-words">
{"🐈: " + text}
</ReactMarkdown>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import { conversationService } from "../api/conversationService"; import { conversationService } from "../api/conversationService";
import { QuestionBubble } from "./QuestionBubble"; import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble"; import { AnswerBubble } from "./AnswerBubble";
@@ -39,8 +39,13 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const [selectedConversation, setSelectedConversation] = const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null); useState<Conversation | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const handleSelectConversation = (conversation: Conversation) => { const handleSelectConversation = (conversation: Conversation) => {
setShowConversations(false); setShowConversations(false);
setSelectedConversation(conversation); setSelectedConversation(conversation);
@@ -91,6 +96,10 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
loadConversations(); loadConversations();
}, []); }, []);
useEffect(() => {
scrollToBottom();
}, [messages]);
useEffect(() => { useEffect(() => {
const loadMessages = async () => { const loadMessages = async () => {
if (selectedConversation == null) return; if (selectedConversation == null) return;
@@ -112,8 +121,11 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
}, [selectedConversation]); }, [selectedConversation]);
const handleQuestionSubmit = async () => { const handleQuestionSubmit = async () => {
if (!query.trim()) return; // Don't submit empty messages
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
if (simbaMode) { if (simbaMode) {
console.log("simba mode activated"); console.log("simba mode activated");
@@ -142,7 +154,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setMessages( setMessages(
currMessages.concat([{ text: result.response, speaker: "simba" }]), currMessages.concat([{ text: result.response, speaker: "simba" }]),
); );
setQuery(""); // Clear input after successful send
} catch (error) { } catch (error) {
console.error("Failed to send query:", error); console.error("Failed to send query:", error);
// If session expired, redirect to login // If session expired, redirect to login
@@ -156,18 +167,26 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setQuery(event.target.value); setQuery(event.target.value);
}; };
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Submit on Enter, but allow Shift+Enter for new line
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleQuestionSubmit();
}
};
return ( return (
<div className="h-screen bg-opacity-20"> <div className="h-screen bg-opacity-20">
<div className="bg-white/85 h-screen"> <div className="bg-white/85 h-screen">
<div className="flex flex-row justify-center py-4"> <div className="flex flex-row justify-center py-4">
<div className="flex flex-col gap-4 min-w-xl max-w-xl"> <div className="flex flex-col gap-4 w-full px-4 sm:w-11/12 sm:max-w-2xl lg:max-w-4xl sm:px-0">
<div className="flex flex-row justify-between"> <div className="flex flex-col sm:flex-row gap-3 sm:gap-0 sm:justify-between">
<header className="flex flex-row justify-center gap-2 sticky top-0 z-10 bg-white"> <header className="flex flex-row justify-center gap-2 sticky top-0 z-10 bg-white">
<h1 className="text-3xl">ask simba!</h1> <h1 className="text-2xl sm:text-3xl">ask simba!</h1>
</header> </header>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2 justify-center sm:justify-end">
<button <button
className="p-2 border border-green-400 bg-green-200 hover:bg-green-400 cursor-pointer rounded-md" className="p-2 h-11 border border-green-400 bg-green-200 hover:bg-green-400 cursor-pointer rounded-md text-sm sm:text-base"
onClick={() => setShowConversations(!showConversations)} onClick={() => setShowConversations(!showConversations)}
> >
{showConversations {showConversations
@@ -175,7 +194,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
: "show conversations"} : "show conversations"}
</button> </button>
<button <button
className="p-2 border border-red-400 bg-red-200 hover:bg-red-400 cursor-pointer rounded-md" className="p-2 h-11 border border-red-400 bg-red-200 hover:bg-red-400 cursor-pointer rounded-md text-sm sm:text-base"
onClick={() => setAuthenticated(false)} onClick={() => setAuthenticated(false)}
> >
logout logout
@@ -195,29 +214,34 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
} }
return <QuestionBubble key={index} text={msg.text} />; return <QuestionBubble key={index} text={msg.text} />;
})} })}
<div ref={messagesEndRef} />
<footer className="flex flex-col gap-2 sticky bottom-0"> <footer className="flex flex-col gap-2 sticky bottom-0">
<div className="flex flex-row justify-between gap-2 grow"> <div className="flex flex-row justify-between gap-2 grow">
<textarea <textarea
className="p-4 border border-blue-200 rounded-md grow bg-white" className="p-3 sm:p-4 border border-blue-200 rounded-md grow bg-white min-h-[44px] resize-y"
onChange={handleQueryChange} onChange={handleQueryChange}
onKeyDown={handleKeyDown}
value={query} value={query}
rows={2}
placeholder="Type your message... (Press Enter to send, Shift+Enter for new line)"
/> />
</div> </div>
<div className="flex flex-row justify-between gap-2 grow"> <div className="flex flex-row justify-between gap-2 grow">
<button <button
className="p-4 border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow" className="p-3 sm:p-4 min-h-[44px] border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow text-sm sm:text-base"
onClick={() => handleQuestionSubmit()} onClick={() => handleQuestionSubmit()}
type="submit" type="submit"
> >
Submit Submit
</button> </button>
</div> </div>
<div className="flex flex-row justify-center gap-2 grow"> <div className="flex flex-row justify-center gap-2 grow items-center">
<input <input
type="checkbox" type="checkbox"
onChange={(event) => setSimbaMode(event.target.checked)} onChange={(event) => setSimbaMode(event.target.checked)}
className="w-5 h-5 cursor-pointer"
/> />
<p>simba mode?</p> <p className="text-sm sm:text-base">simba mode?</p>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -38,22 +38,25 @@ export const ConversationList = ({
}, []); }, []);
return ( return (
<div className="bg-indigo-300 rounded-md p-3 flex flex-col"> <div className="bg-indigo-300 rounded-md p-3 sm:p-4 flex flex-col gap-1">
{conservations.map((conversation) => { {conservations.map((conversation) => {
return ( return (
<div <div
className="border-blue-400 bg-indigo-300 hover:bg-indigo-200 cursor-pointer rounded-md p-2" key={conversation.id}
className="border-blue-400 bg-indigo-300 hover:bg-indigo-200 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
onClick={() => onSelectConversation(conversation)} onClick={() => onSelectConversation(conversation)}
> >
<p>{conversation.title}</p> <p className="text-sm sm:text-base break-words">
{conversation.title}
</p>
</div> </div>
); );
})} })}
<div <div
className="border-blue-400 bg-indigo-300 hover:bg-indigo-200 cursor-pointer rounded-md p-2" className="border-blue-400 bg-indigo-300 hover:bg-indigo-200 cursor-pointer rounded-md p-3 min-h-[44px] flex items-center"
onClick={() => onCreateNewConversation()} onClick={() => onCreateNewConversation()}
> >
<p> + Start a new thread</p> <p className="text-sm sm:text-base"> + Start a new thread</p>
</div> </div>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { userService } from "../api/userService"; import { userService } from "../api/userService";
type LoginScreenProps = { type LoginScreenProps = {
@@ -9,8 +9,23 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
const [username, setUsername] = useState<string>(""); const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>(""); const [password, setPassword] = useState<string>("");
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [isChecking, setIsChecking] = useState<boolean>(true);
useEffect(() => {
// Check if user is already authenticated
const checkAuth = async () => {
const isValid = await userService.validateToken();
if (isValid) {
setAuthenticated(true);
}
setIsChecking(false);
};
checkAuth();
}, [setAuthenticated]);
const handleLogin = async (e?: React.FormEvent) => {
e?.preventDefault();
const handleLogin = async () => {
if (!username || !password) { if (!username || !password) {
setError("Please enter username and password"); setError("Please enter username and password");
return; return;
@@ -28,46 +43,73 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
} }
}; };
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleLogin();
}
};
// Show loading state while checking authentication
if (isChecking) {
return (
<div className="h-screen bg-opacity-20">
<div className="bg-white/85 h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-lg sm:text-xl">Checking authentication...</p>
</div>
</div>
</div>
);
}
return ( return (
<div className="h-screen bg-opacity-20"> <div className="h-screen bg-opacity-20">
<div className="bg-white/85 h-screen"> <div className="bg-white/85 h-screen">
<div className="flex flex-row justify-center py-4"> <div className="flex flex-row justify-center py-4">
<div className="flex flex-col gap-4 min-w-xl max-w-xl"> <div className="flex flex-col gap-4 w-full px-4 sm:w-11/12 sm:max-w-2xl lg:max-w-4xl sm:px-0">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex flex-grow justify-center w-full bg-amber-400"> <div className="flex flex-grow justify-center w-full bg-amber-400 p-2">
<h1 className="text-xl font-bold"> <h1 className="text-base sm:text-xl font-bold text-center">
I AM LOOKING FOR A DESIGNER. THIS APP WILL REMAIN UGLY UNTIL A I AM LOOKING FOR A DESIGNER. THIS APP WILL REMAIN UGLY UNTIL A
DESIGNER COMES. DESIGNER COMES.
</h1> </h1>
</div> </div>
<header className="flex flex-row justify-center gap-2 grow sticky top-0 z-10 bg-white"> <header className="flex flex-row justify-center gap-2 grow sticky top-0 z-10 bg-white">
<h1 className="text-3xl">ask simba!</h1> <h1 className="text-2xl sm:text-3xl">ask simba!</h1>
</header> </header>
<label htmlFor="username">username</label> <label htmlFor="username" className="text-sm sm:text-base">
username
</label>
<input <input
type="text" type="text"
id="username" id="username"
name="username" name="username"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
className="border border-s-slate-950 p-3 rounded-md" onKeyPress={handleKeyPress}
className="border border-s-slate-950 p-3 rounded-md min-h-[44px]"
/> />
<label htmlFor="password">password</label> <label htmlFor="password" className="text-sm sm:text-base">
password
</label>
<input <input
type="password" type="password"
id="password" id="password"
name="password" name="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="border border-s-slate-950 p-3 rounded-md" onKeyPress={handleKeyPress}
className="border border-s-slate-950 p-3 rounded-md min-h-[44px]"
/> />
{error && ( {error && (
<div className="text-red-600 font-semibold">{error}</div> <div className="text-red-600 font-semibold text-sm sm:text-base">
{error}
</div>
)} )}
</div> </div>
<button <button
className="p-4 border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow" className="p-3 sm:p-4 min-h-[44px] border border-blue-400 bg-blue-200 hover:bg-blue-400 cursor-pointer rounded-md flex-grow text-sm sm:text-base"
onClick={handleLogin} onClick={handleLogin}
> >
login login

View File

@@ -3,5 +3,9 @@ type QuestionBubbleProps = {
}; };
export const QuestionBubble = ({ text }: QuestionBubbleProps) => { export const QuestionBubble = ({ text }: QuestionBubbleProps) => {
return <div className="rounded-md bg-stone-200 p-3">🤦: {text}</div>; return (
<div className="rounded-md bg-stone-200 p-3 sm:p-4 break-words overflow-wrap-anywhere text-sm sm:text-base">
🤦: {text}
</div>
);
}; };