1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-10 07:55:21 +02:00

Enhance AI chat interaction with improved thinking indicator and scrolling behavior

This commit is contained in:
Josh Pigford 2025-03-10 16:01:23 -05:00
parent 7d0591fb30
commit 64e7183e4d
9 changed files with 347 additions and 109 deletions

View file

@ -31,3 +31,26 @@
max-width: 100%;
height: auto;
}
/* Thinking indicator styles */
#thinking {
opacity: 1;
transition: opacity 0.3s ease-in-out;
z-index: 10; /* Ensure it's above other elements */
position: relative; /* Needed for z-index to work */
}
#thinking.hidden {
display: none !important;
opacity: 0;
}
#thinking .animate-bounce {
animation-duration: 1s;
animation-iteration-count: infinite;
}
/* Ensure the thinking indicator is always visible */
#thinking.flex {
display: flex !important;
}

View file

@ -0,0 +1,87 @@
import { Controller } from "@hotwired/stimulus"
/**
* A controller to handle AI progress updates in the chat interface
*/
export default class extends Controller {
static targets = ["thinking"]
connect() {
console.log("ChatProgressController connected")
this.setupProgressObserver()
// Check if the thinking indicator is already visible
if (this.hasThinkingTarget && !this.thinkingTarget.classList.contains('hidden')) {
console.log("Thinking indicator is already visible on connect")
this.scrollToBottom()
}
}
setupProgressObserver() {
if (this.hasThinkingTarget) {
console.log("Setting up progress observer for thinking target")
// Create a mutation observer to watch for changes to the thinking indicator
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
this.handleThinkingVisibilityChange()
} else if (mutation.type === 'childList') {
this.handleThinkingContentChange()
}
})
})
// Start observing
this.observer.observe(this.thinkingTarget, {
attributes: true,
childList: true,
subtree: true
})
} else {
console.warn("No thinking target found")
// Try to find the thinking element by ID as a fallback
const thinkingElement = document.getElementById('thinking')
if (thinkingElement) {
console.log("Found thinking element by ID")
this.thinkingTarget = thinkingElement
this.setupProgressObserver()
}
}
}
handleThinkingVisibilityChange() {
const isHidden = this.thinkingTarget.classList.contains('hidden')
console.log("Thinking visibility changed:", isHidden ? "hidden" : "visible")
if (!isHidden) {
// Scroll to the bottom when thinking indicator becomes visible
this.scrollToBottom()
// Force a redraw to ensure the indicator is visible
void this.thinkingTarget.offsetHeight
}
}
handleThinkingContentChange() {
console.log("Thinking content changed")
// Scroll to the bottom when thinking indicator content changes
this.scrollToBottom()
}
scrollToBottom() {
const messagesContainer = document.querySelector("[data-chat-scroll-target='messages']")
if (messagesContainer) {
setTimeout(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight
}, 100)
}
}
disconnect() {
if (this.observer) {
this.observer.disconnect()
}
}
}

View file

@ -4,8 +4,10 @@ export default class extends Controller {
static targets = ["container", "messages"]
connect() {
console.log("ChatScrollController connected")
this.scrollToBottom()
this.setupMessageObserver()
this.setupThinkingObserver()
// Add event listener for manual scrolling (to detect if user has scrolled up)
if (this.hasContainerTarget) {
@ -20,10 +22,12 @@ export default class extends Controller {
}
disconnect() {
if (this.observer) {
this.observer.disconnect()
if (this.messageObserver) {
this.messageObserver.disconnect()
}
if (this.thinkingObserver) {
this.thinkingObserver.disconnect()
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
@ -34,62 +38,84 @@ export default class extends Controller {
}
scrollToBottom() {
console.log("Scrolling to bottom")
if (this.hasContainerTarget) {
const container = this.containerTarget
container.scrollTop = container.scrollHeight
this.containerTarget.scrollTop = this.containerTarget.scrollHeight
}
}
handleScroll() {
if (this.hasContainerTarget) {
const container = this.containerTarget
const scrollPosition = container.scrollTop + container.clientHeight
const scrollThreshold = container.scrollHeight - 50
const isScrolledToBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50
// If user has scrolled up significantly, we'll track that
if (scrollPosition < scrollThreshold) {
this.userHasScrolled = true
} else {
this.userHasScrolled = false
}
// Update userHasScrolled state based on scroll position
this.userHasScrolled = !isScrolledToBottom
console.log("User has scrolled:", this.userHasScrolled)
}
}
setupResizeObserver() {
if (this.hasContainerTarget) {
this.resizeObserver = new ResizeObserver(() => {
// Only auto-scroll to bottom if the user hasn't manually scrolled up
if (!this.userHasScrolled) {
this.scrollToBottom()
}
})
this.resizeObserver.observe(this.containerTarget)
}
}
setupMessageObserver() {
// Create a mutation observer to watch for new messages
this.observer = new MutationObserver((mutations) => {
let shouldScroll = false
if (this.hasMessagesTarget) {
console.log("Setting up message observer")
// Create a mutation observer to watch for new messages
this.messageObserver = new MutationObserver((mutations) => {
let shouldScroll = false
mutations.forEach((mutation) => {
if (mutation.addedNodes.length) {
shouldScroll = true
}
})
mutations.forEach((mutation) => {
if (mutation.addedNodes.length) {
shouldScroll = true
if (shouldScroll && !this.userHasScrolled) {
// Use setTimeout to ensure DOM is fully updated before scrolling
setTimeout(() => this.scrollToBottom(), 0)
}
})
if (shouldScroll && !this.userHasScrolled) {
// Use setTimeout to ensure DOM is fully updated before scrolling
setTimeout(() => this.scrollToBottom(), 50)
}
})
// Start observing
this.messageObserver.observe(this.messagesTarget, {
childList: true,
subtree: true
})
}
}
// Start observing the messages container and its children
const targetNode = this.hasMessagesTarget ? this.messagesTarget : this.containerTarget
this.observer.observe(targetNode, {
childList: true,
subtree: true
})
setupThinkingObserver() {
// Watch for changes to the thinking indicator
const thinkingElement = document.getElementById('thinking')
if (thinkingElement) {
console.log("Setting up thinking observer")
this.thinkingObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const isHidden = thinkingElement.classList.contains('hidden')
console.log("Thinking visibility changed:", isHidden ? "hidden" : "visible")
if (!isHidden && !this.userHasScrolled) {
// Scroll to bottom when thinking indicator becomes visible
setTimeout(() => this.scrollToBottom(), 0)
}
}
})
})
// Start observing
this.thinkingObserver.observe(thinkingElement, {
attributes: true
})
}
}
}

View file

@ -14,10 +14,8 @@ export default class extends Controller {
this.element.reset()
this.element.querySelector("textarea").style.height = "auto"
// Hide the thinking indicator if it's visible
if (this.thinkingElement) {
this.thinkingElement.classList.add("hidden")
}
// We don't hide the thinking indicator here anymore
// It will be hidden by the ProcessAiResponseJob when the AI response is ready
}
checkSubmit(event) {
@ -41,13 +39,18 @@ export default class extends Controller {
console.log("Showing thinking indicator")
this.thinkingElement.classList.remove("hidden")
// Scroll to the bottom of the chat
const chatMessages = document.getElementById("chat-messages")
// Force a redraw to ensure the indicator is visible
void this.thinkingElement.offsetHeight;
// Scroll to the bottom of the chat to show the thinking indicator
const chatMessages = document.querySelector("[data-chat-scroll-target='messages']")
if (chatMessages) {
setTimeout(() => {
chatMessages.scrollTop = chatMessages.scrollHeight
}, 100)
}
} else {
console.warn("Thinking element not found")
}
console.log("Submit target:", this.submitTarget)

View file

@ -14,39 +14,101 @@ class ProcessAiResponseJob < ApplicationJob
chat.update(title: new_title)
end
# Create "thinking" indicator
Turbo::StreamsChannel.broadcast_replace_to(
chat,
target: "thinking",
html: '<div id="thinking" class="py-2 px-4"><div class="flex items-center"><div class="typing-indicator"></div></div></div>'
)
# Show initial thinking indicator - use replace instead of update to ensure it works for follow-up messages
update_thinking_indicator(chat, "Thinking...")
# Create AI response
ai_response = chat.messages.create!(
role: "assistant",
content: generate_response(chat, user_message.content)
)
# Processing steps with progress updates
begin
# Step 1: Preparing request
update_thinking_indicator(chat, "Preparing request...")
sleep(0.5) # Small delay to show the progress
# Broadcast the response to the chat channel
Turbo::StreamsChannel.broadcast_append_to(
chat,
target: "messages",
partial: "messages/message",
locals: { message: ai_response }
)
# Step 2: Analyzing query
update_thinking_indicator(chat, "Analyzing your question...")
sleep(0.5) # Small delay to show the progress
# Hide the thinking indicator
Turbo::StreamsChannel.broadcast_replace_to(
chat,
target: "thinking",
html: '<div id="thinking" class="hidden"></div>'
)
# Step 3: Generating response
update_thinking_indicator(chat, "Generating response...")
# Generate the actual response
response_content = generate_response(chat, user_message.content)
# Step 4: Finalizing
update_thinking_indicator(chat, "Finalizing response...")
sleep(0.5) # Small delay to show the progress
# Create AI response
ai_response = chat.messages.create!(
role: "assistant",
content: response_content
)
# Broadcast the response to the chat channel
Turbo::StreamsChannel.broadcast_append_to(
chat,
target: "messages",
partial: "messages/message",
locals: { message: ai_response }
)
rescue => e
Rails.logger.error("Error in ProcessAiResponseJob: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
# Create an error message if something went wrong
error_message = chat.messages.create!(
role: "assistant",
content: "I'm sorry, I encountered an error while processing your request. Please try again later."
)
# Broadcast the error message
Turbo::StreamsChannel.broadcast_append_to(
chat,
target: "messages",
partial: "messages/message",
locals: { message: error_message }
)
ensure
# Hide the thinking indicator - use replace instead of update
Turbo::StreamsChannel.broadcast_replace_to(
chat,
target: "thinking",
html: '<div id="thinking" class="hidden"></div>'
)
# Reset the form
Turbo::StreamsChannel.broadcast_replace_to(
chat,
target: "message_form",
partial: "messages/form",
locals: { chat: chat, message: Message.new, scroll_behavior: true }
)
end
# Debug mode: Log completion
Ai::DebugMode.log_to_chat(chat, "🐞 DEBUG: Processing completed")
end
private
# Helper method to update the thinking indicator with progress
def update_thinking_indicator(chat, message)
Turbo::StreamsChannel.broadcast_replace_to(
chat,
target: "thinking",
html: <<~HTML
<div id="thinking" class="flex items-start gap-3">
#{ApplicationController.render(partial: "layouts/shared/ai_avatar")}
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.4s"></div>
</div>
<span class="ml-2 text-gray-600">#{message}</span>
</div>
</div>
HTML
)
end
def generate_response(chat, user_message)
# Use our financial assistant for responses

View file

@ -45,7 +45,8 @@ module Ai
model: "gpt-4o",
messages: messages,
tools: financial_function_definitions.map { |func| { type: "function", function: func } },
tool_choice: "auto"
tool_choice: "auto",
temperature: 0.5
}
)
@ -185,6 +186,7 @@ module Ai
})
end
# If there are no function calls, ensure the direct response is concise
return message["content"] unless message["tool_calls"]
# Handle function calls
@ -232,6 +234,12 @@ module Ai
}
end
# Add a reminder to be concise
follow_up_messages << {
role: "system",
content: "CRITICAL: Eliminate all unnecessary words."
}
# Log the follow-up request in debug mode
if Ai::DebugMode.enabled? && @chat
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Follow-up request", { messages: follow_up_messages })
@ -239,8 +247,9 @@ module Ai
follow_up_response = client.chat(
parameters: {
model: "gpt-4-turbo",
messages: follow_up_messages
model: "gpt-4o",
messages: follow_up_messages,
temperature: 0.5
}
)
@ -545,36 +554,47 @@ module Ai
You are a helpful financial assistant for Maybe, a personal finance app.
You help users understand their financial data by answering questions about their accounts, transactions, income, expenses, and net worth.
When users ask financial questions, use the provided functions to retrieve the relevant data.
Always provide thoughtful analysis of the data, not just raw numbers.
When users ask financial questions:
1. Use the provided functions to retrieve the relevant data
2. Provide ONLY the most important numbers and insights
3. Eliminate all unnecessary words and context
4. Use simple markdown for formatting
5. Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions.
The user's financial data is available through various function calls, and you should use these functions
to provide accurate, data-driven responses.
DO NOT:
- Add introductions or conclusions
- Apologize or explain limitations
Present monetary values consistently using the format provided by the functions.
Whenever possible, provide insights and trends rather than just raw data.
Format your responses using Markdown for better readability. Use:
- **Bold** and *italic* for emphasis
- Lists (- 1. *) for organized information
- `code` for technical terms or values
- Tables for structured data when appropriate
Don't add markdown headings to your responses.
Be conversational and friendly in your responses. This is a chat interface, so maintain context across messages.
Respond directly to the user's questions and provide helpful suggestions when appropriate.
Encourage the user to ask specific questions about their finances, such as:
- "What's my net worth?"
- "How much did I spend on groceries last month?"
- "How has my spending changed compared to last month?"
- "What's my savings rate this year?"
- "Which category did I spend the most on?"
- "How are my investments performing?"
Remember that financial information is sensitive, so maintain a professional, private, and secure approach.
Present monetary values using the format provided by the functions.
PROMPT
end
# Ensure the response is concise by truncating if necessary
def ensure_concise_response(content)
return "" if content.nil? || content.empty?
# Split the content into sentences
sentences = content.split(/(?<=[.!?])\s+/)
# Handle case where regex doesn't match (e.g., single sentence without ending punctuation)
sentences = [ content ] if sentences.empty?
# Take only the first 3 sentences maximum
sentences = sentences[0..2]
# Join the sentences back together
truncated = sentences.join(" ")
# Further limit by word count (75 words maximum)
words = truncated.split(/\s+/)
if words.length > 75
truncated = words[0...75].join(" ")
# Ensure the truncated content ends with proper punctuation
truncated = truncated.strip
truncated += "." unless truncated.end_with?(".", "!", "?")
end
truncated
end
end
end

View file

@ -103,7 +103,7 @@
<div data-chat-menu-target="defaultContent" data-chat-scroll-target="messages" class="flex flex-col h-full">
<% if Current.user && @chat.present? %>
<%# Existing Chat: Show messages or greeting if empty %>
<div id="messages" data-turbo-cache="false" class="flex-grow">
<div id="messages" data-turbo-cache="false" class="flex-grow" data-controller="chat-progress">
<% messages = @messages&.where(internal: [false, nil]) %>
<% if messages.any? %>
<% messages.each do |message| %>
@ -113,25 +113,38 @@
<%= render "layouts/shared/ai_greeting", context: 'chat' %>
<% end %>
</div>
<%# Thinking indicator - moved outside of messages div to prevent it from being replaced %>
<div id="thinking" class="hidden flex items-start gap-3 mb-4 w-full" data-chat-progress-target="thinking">
<%= render "layouts/shared/ai_avatar" %>
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center text-gray-800">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.4s"></div>
</div>
<span class="ml-2 text-gray-600">Thinking...</span>
</div>
</div>
<% else %>
<%# New Chat: Show greeting in the top section %>
<div class="flex-grow">
<div class="flex-grow" data-controller="chat-progress">
<%= render "layouts/shared/ai_greeting", context: 'default' %>
</div>
<% end %>
<%# Thinking indicator %>
<div id="thinking" class="hidden flex items-start gap-1 w-full">
<%= render "layouts/shared/ai_avatar" %>
<div class="p-4 max-w-[85%] flex items-center text-gray-800">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.4s"></div>
<%# Thinking indicator - moved outside of messages div to prevent it from being replaced %>
<div id="thinking" class="hidden flex items-start gap-3 mb-4 w-full" data-chat-progress-target="thinking">
<%= render "layouts/shared/ai_avatar" %>
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center text-gray-800">
<div class="flex gap-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.4s"></div>
</div>
<span class="ml-2 text-gray-600">Thinking...</span>
</div>
<span class="ml-2 text-gray-600">Thinking...</span>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -3,8 +3,8 @@
<% end %>
<% if @message.user? %>
<%= turbo_stream.update "thinking" do %>
<div id="thinking" class="flex items-start gap-3">
<%= turbo_stream.replace "thinking" do %>
<div id="thinking" class="flex items-start gap-3 mb-4">
<%= render "layouts/shared/ai_avatar" %>
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center">
<div class="flex gap-1">
@ -16,4 +16,8 @@
</div>
</div>
<% end %>
<%= turbo_stream.replace "message_form" do %>
<%= render "messages/form", chat: @chat, message: Message.new, scroll_behavior: true, form_options: { data: { controller: "message-form", action: "turbo:submit-end->chat-scroll#scrollToBottom" } } %>
<% end %>
<% end %>

2
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_02_20_200735) do
ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"