Consolidate Stimulus controllers, thinking state, controllers, and views
This commit is contained in:
@@ -316,8 +316,8 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
form>button {
|
||||
@apply cursor-pointer;
|
||||
button {
|
||||
@apply cursor-pointer focus-visible:outline-gray-900;
|
||||
}
|
||||
|
||||
hr {
|
||||
|
||||
@@ -11,13 +11,6 @@ class ChatsController < ApplicationController
|
||||
|
||||
def show
|
||||
set_last_viewed_chat(@chat)
|
||||
@messages = @chat.messages.conversation.ordered
|
||||
@message = Message.new
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
@@ -25,40 +18,15 @@ class ChatsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
@chat = Current.user.chats.new(chat_params.merge(title: "New Chat"))
|
||||
@chat = Current.user.chats.create_from_message!(chat_params[:content])
|
||||
@message = @chat.messages.user.first
|
||||
|
||||
if @chat.save
|
||||
set_last_viewed_chat(@chat)
|
||||
set_last_viewed_chat(@chat)
|
||||
|
||||
# Create initial system message with enhanced financial assistant context
|
||||
@chat.messages.create(
|
||||
content: "You are a helpful financial assistant for Maybe. You can answer questions about the user's finances including net worth, account balances, income, expenses, spending patterns, budgets, and financial goals. You have access to the user's financial data and can provide insights based on their transactions and accounts. Be conversational, helpful, and provide specific financial insights tailored to the user's question.",
|
||||
role: "developer",
|
||||
)
|
||||
# TODO: Enable again
|
||||
# ProcessAiResponseJob.perform_later(@message)
|
||||
|
||||
# Create user message if content is provided
|
||||
if params[:content].present?
|
||||
@user_message = @chat.messages.create(
|
||||
content: params[:content],
|
||||
role: "user",
|
||||
)
|
||||
end
|
||||
|
||||
# Process AI response if user message was created
|
||||
if defined?(@user_message) && @user_message.persisted?
|
||||
ProcessAiResponseJob.perform_later(@chat.id, @user_message.id)
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to root_path(chat_id: @chat.id) }
|
||||
format.turbo_stream { redirect_to root_path(chat_id: @chat.id, format: :html) }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to chats_path, alert: "Failed to create chat" }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace("chat_form", partial: "chats/form", locals: { chat: @chat }) }
|
||||
end
|
||||
end
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -76,6 +44,7 @@ class ChatsController < ApplicationController
|
||||
def destroy
|
||||
@chat.destroy
|
||||
clear_last_viewed_chat
|
||||
|
||||
redirect_to chats_path, notice: "Chat was successfully deleted"
|
||||
end
|
||||
|
||||
|
||||
@@ -3,28 +3,18 @@ class MessagesController < ApplicationController
|
||||
before_action :ensure_ai_enabled
|
||||
|
||||
def create
|
||||
@message = @chat.messages.new(message_params)
|
||||
@message.user = Current.user
|
||||
@message.role = "user"
|
||||
@message = @chat.messages.create!(message_params)
|
||||
|
||||
if @message.save
|
||||
respond_to do |format|
|
||||
format.html { redirect_to root_path(chat_id: @chat.id) }
|
||||
format.turbo_stream
|
||||
end
|
||||
# TODO: Enable again
|
||||
# ProcessAiResponseJob.perform_later(@message)
|
||||
|
||||
# Process AI response in background
|
||||
ProcessAiResponseJob.perform_later(@chat.id, @message.id)
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to root_path(chat_id: @chat.id), alert: "Failed to send message" }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace("message_form", partial: "messages/form", locals: { chat: @chat, message: @message }) }
|
||||
end
|
||||
respond_to do |format|
|
||||
format.html { redirect_to chat_path(@chat) }
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_chat
|
||||
@chat = Current.user.chats.find(params[:chat_id])
|
||||
end
|
||||
|
||||
@@ -154,13 +154,6 @@ module ApplicationHelper
|
||||
markdown.render(text).html_safe
|
||||
end
|
||||
|
||||
def chat_view_path(chat)
|
||||
return new_chat_path if params[:chat_view] == "new"
|
||||
return chats_path if chat.nil? || params[:chat_view] == "all"
|
||||
|
||||
chat.persisted? ? chat_path(chat) : new_chat_path
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_total(item, money_method, negate)
|
||||
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
|
||||
|
||||
12
app/helpers/chats_helper.rb
Normal file
12
app/helpers/chats_helper.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module ChatsHelper
|
||||
def chat_frame
|
||||
:sidebar_chat
|
||||
end
|
||||
|
||||
def chat_view_path(chat)
|
||||
return new_chat_path if params[:chat_view] == "new"
|
||||
return chats_path if chat.nil? || params[:chat_view] == "all"
|
||||
|
||||
chat.persisted? ? chat_path(chat) : new_chat_path
|
||||
end
|
||||
end
|
||||
@@ -13,6 +13,13 @@ module MenusHelper
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_item(label, url:, icon:, turbo_frame: nil)
|
||||
link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
|
||||
button_to url,
|
||||
method: :delete,
|
||||
|
||||
@@ -1,96 +1,61 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["messages", "form", "input"]
|
||||
static targets = ["messages", "form", "input"];
|
||||
|
||||
connect() {
|
||||
this.scrollToBottom()
|
||||
this.setupAutoResize()
|
||||
this.setupMessageObserver()
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
if (this.hasMessagesTarget) {
|
||||
const messagesContainer = this.messagesTarget.closest('#chat-container')
|
||||
if (messagesContainer) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupAutoResize() {
|
||||
if (this.hasInputTarget) {
|
||||
this.inputTarget.addEventListener('input', this.autoResize.bind(this))
|
||||
// Initialize height
|
||||
this.autoResize()
|
||||
}
|
||||
}
|
||||
|
||||
setupMessageObserver() {
|
||||
if (this.hasMessagesTarget) {
|
||||
// Create a mutation observer to watch for new messages
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
let shouldScroll = false
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.addedNodes.length) {
|
||||
shouldScroll = true
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldScroll) {
|
||||
// Use setTimeout to ensure DOM is fully updated before scrolling
|
||||
setTimeout(() => this.scrollToBottom(), 0)
|
||||
}
|
||||
})
|
||||
|
||||
// Start observing
|
||||
this.observer.observe(this.messagesTarget, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
}
|
||||
this.#configureAutoScroll();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up observer when controller is disconnected
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
if (this.messagesObserver) {
|
||||
this.messagesObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
autoResize() {
|
||||
const input = this.inputTarget
|
||||
// Reset height to calculate proper scrollHeight
|
||||
input.style.height = 'auto'
|
||||
// Set new height based on content
|
||||
input.style.height = `${input.scrollHeight}px`
|
||||
// Cap at 150px max height
|
||||
if (input.scrollHeight > 150) {
|
||||
input.style.height = '150px'
|
||||
input.style.overflowY = 'auto'
|
||||
} else {
|
||||
input.style.overflowY = 'hidden'
|
||||
const input = this.inputTarget;
|
||||
const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px)
|
||||
const maxLines = 3; // 3 lines = 60px total
|
||||
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
|
||||
input.style.overflowY =
|
||||
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
submitSampleQuestion(e) {
|
||||
this.inputTarget.value = e.target.dataset.chatQuestionParam;
|
||||
|
||||
setTimeout(() => {
|
||||
this.formTarget.requestSubmit();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others)
|
||||
handleInputKeyDown(e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.formTarget.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
submit(event) {
|
||||
// Let the form submit normally, but prepare for the response
|
||||
this.startLoadingState()
|
||||
#configureAutoScroll() {
|
||||
this.messagesObserver = new MutationObserver((_mutations) => {
|
||||
if (this.hasMessagesTarget) {
|
||||
this.#scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to entire sidebar for changes, always try to scroll to the bottom
|
||||
this.messagesObserver.observe(this.element, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
startLoadingState() {
|
||||
if (this.hasFormTarget) {
|
||||
this.formTarget.classList.add('opacity-50')
|
||||
this.formTarget.querySelector('button[type="submit"]').disabled = true
|
||||
}
|
||||
}
|
||||
|
||||
endLoadingState() {
|
||||
if (this.hasFormTarget) {
|
||||
this.formTarget.classList.remove('opacity-50')
|
||||
this.formTarget.querySelector('button[type="submit"]').disabled = false
|
||||
this.formTarget.reset()
|
||||
this.autoResize()
|
||||
}
|
||||
}
|
||||
}
|
||||
#scrollToBottom = () => {
|
||||
console.log("scrolling to bottom");
|
||||
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* A controller to toggle between chat list and chat content in the sidebar
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "content", "defaultContent", "menuIcon", "backIcon", "header", "listHeader"];
|
||||
|
||||
connect() {
|
||||
this.isShowingChatList = false;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isShowingChatList = !this.isShowingChatList;
|
||||
|
||||
if (this.isShowingChatList) {
|
||||
this.contentTarget.classList.remove("hidden");
|
||||
this.defaultContentTarget.classList.add("hidden");
|
||||
this.menuIconTarget.classList.add("hidden");
|
||||
this.backIconTarget.classList.remove("hidden");
|
||||
this.headerTarget.classList.add("hidden");
|
||||
this.listHeaderTarget.classList.remove("hidden");
|
||||
} else {
|
||||
this.contentTarget.classList.add("hidden");
|
||||
this.defaultContentTarget.classList.remove("hidden");
|
||||
this.menuIconTarget.classList.remove("hidden");
|
||||
this.backIconTarget.classList.add("hidden");
|
||||
this.headerTarget.classList.remove("hidden");
|
||||
this.listHeaderTarget.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
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) {
|
||||
this.containerTarget.addEventListener('scroll', this.handleScroll.bind(this))
|
||||
}
|
||||
|
||||
// Add resize observer to handle container resizing
|
||||
this.setupResizeObserver()
|
||||
|
||||
// Set initial userHasScrolled state
|
||||
this.userHasScrolled = false
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.messageObserver) {
|
||||
this.messageObserver.disconnect()
|
||||
}
|
||||
if (this.thinkingObserver) {
|
||||
this.thinkingObserver.disconnect()
|
||||
}
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
}
|
||||
|
||||
if (this.hasContainerTarget) {
|
||||
this.containerTarget.removeEventListener('scroll', this.handleScroll.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
console.log("Scrolling to bottom")
|
||||
if (this.hasContainerTarget) {
|
||||
this.containerTarget.scrollTop = this.containerTarget.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
handleScroll() {
|
||||
if (this.hasContainerTarget) {
|
||||
const container = this.containerTarget
|
||||
const isScrolledToBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 50
|
||||
|
||||
// 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(() => {
|
||||
if (!this.userHasScrolled) {
|
||||
this.scrollToBottom()
|
||||
}
|
||||
})
|
||||
this.resizeObserver.observe(this.containerTarget)
|
||||
}
|
||||
}
|
||||
|
||||
setupMessageObserver() {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldScroll && !this.userHasScrolled) {
|
||||
// Use setTimeout to ensure DOM is fully updated before scrolling
|
||||
setTimeout(() => this.scrollToBottom(), 0)
|
||||
}
|
||||
})
|
||||
|
||||
// Start observing
|
||||
this.messageObserver.observe(this.messagesTarget, {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="example-question"
|
||||
export default class extends Controller {
|
||||
static values = { text: String }
|
||||
|
||||
fillForm(event) {
|
||||
event.preventDefault()
|
||||
|
||||
// Find the textarea in the form
|
||||
const textarea = document.querySelector("textarea[name='message[content]']")
|
||||
if (!textarea) return
|
||||
|
||||
// Set the value and focus
|
||||
textarea.value = this.textValue
|
||||
textarea.focus()
|
||||
|
||||
// Trigger input event to resize the textarea if using autogrow
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["submit"]
|
||||
|
||||
connect() {
|
||||
console.log("MessageFormController connected")
|
||||
this.thinkingElement = document.getElementById("thinking")
|
||||
console.log("Thinking element:", this.thinkingElement)
|
||||
}
|
||||
|
||||
reset() {
|
||||
console.log("MessageFormController reset called")
|
||||
this.element.reset()
|
||||
this.element.querySelector("textarea").style.height = "auto"
|
||||
|
||||
// We don't hide the thinking indicator here anymore
|
||||
// It will be hidden by the ProcessAiResponseJob when the AI response is ready
|
||||
}
|
||||
|
||||
checkSubmit(event) {
|
||||
console.log("MessageFormController checkSubmit called", event.key)
|
||||
// Submit the form when Enter is pressed without Shift
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
this.submitForm()
|
||||
}
|
||||
}
|
||||
|
||||
submitForm() {
|
||||
console.log("MessageFormController submitForm called")
|
||||
|
||||
// Get the form action to determine if we're creating a new chat or adding to existing
|
||||
const isNewChat = this.element.action.includes('/chats') && !this.element.action.includes('/messages');
|
||||
console.log("Is new chat:", isNewChat);
|
||||
|
||||
// Show the thinking indicator
|
||||
if (this.thinkingElement) {
|
||||
console.log("Showing thinking indicator")
|
||||
this.thinkingElement.classList.remove("hidden")
|
||||
|
||||
// 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)
|
||||
this.element.requestSubmit(this.submitTarget)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus";
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
userId: String,
|
||||
side: { type: String, default: "left" } // "left" or "right"
|
||||
side: { type: String, default: "left" }, // "left" or "right"
|
||||
};
|
||||
static targets = ["leftPanel", "rightPanel", "content"];
|
||||
|
||||
@@ -13,7 +13,8 @@ export default class extends Controller {
|
||||
const side = event.currentTarget.dataset.side || this.sideValue;
|
||||
|
||||
// Get the appropriate panel based on the side
|
||||
const panel = side === "left" ? this.leftPanelTarget : this.rightPanelTarget;
|
||||
const panel =
|
||||
side === "left" ? this.leftPanelTarget : this.rightPanelTarget;
|
||||
|
||||
// Toggle the sidebar visibility
|
||||
if (side === "left") {
|
||||
@@ -25,7 +26,7 @@ export default class extends Controller {
|
||||
// For right panel, use the correct width class
|
||||
panel.classList.toggle("w-0");
|
||||
panel.classList.toggle("opacity-0");
|
||||
panel.classList.toggle("w-[375px]");
|
||||
panel.classList.toggle("w-[400px]");
|
||||
panel.classList.toggle("opacity-100");
|
||||
}
|
||||
|
||||
@@ -37,7 +38,10 @@ export default class extends Controller {
|
||||
this.adjustContentWidth(leftSidebarOpen, rightSidebarOpen);
|
||||
|
||||
// Save user preference
|
||||
this.saveUserPreference(side, side === "left" ? leftSidebarOpen : rightSidebarOpen);
|
||||
this.saveUserPreference(
|
||||
side,
|
||||
side === "left" ? leftSidebarOpen : rightSidebarOpen,
|
||||
);
|
||||
}
|
||||
|
||||
adjustContentWidth(leftSidebarOpen, rightSidebarOpen) {
|
||||
@@ -55,7 +59,8 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
saveUserPreference(side, isOpen) {
|
||||
const preferenceField = side === "left" ? "show_sidebar" : "show_ai_sidebar";
|
||||
const preferenceField =
|
||||
side === "left" ? "show_sidebar" : "show_ai_sidebar";
|
||||
|
||||
fetch(`/users/${this.userIdValue}`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.resize()
|
||||
}
|
||||
|
||||
resize() {
|
||||
const textarea = this.element
|
||||
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textarea.style.height = "auto"
|
||||
|
||||
// Set the height to match the content (with a max height)
|
||||
const newHeight = Math.min(textarea.scrollHeight, 150)
|
||||
textarea.style.height = `${newHeight}px`
|
||||
|
||||
// Scroll to the bottom of the chat when the textarea grows
|
||||
const chatMessages = document.getElementById("chat-messages")
|
||||
if (chatMessages) {
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
class ProcessAiResponseJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(chat_id, message_id)
|
||||
chat = Chat.find(chat_id)
|
||||
user_message = Message.find(message_id)
|
||||
def perform(message)
|
||||
chat = message.chat
|
||||
user_message = message
|
||||
|
||||
# Debug mode: Log the start of processing
|
||||
Ai::DebugMode.log_to_chat(chat, "🐞 DEBUG: Starting to process user query")
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
class Chat < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
has_one :viewer, class_name: "User", foreign_key: :current_chat_id, dependent: :nullify # "Last chat user has viewed"
|
||||
has_one :viewer, class_name: "User", foreign_key: :last_viewed_chat_id, dependent: :nullify # "Last chat user has viewed"
|
||||
has_many :messages, dependent: :destroy
|
||||
|
||||
validates :title, presence: true
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
class << self
|
||||
def create_from_message!(user_message)
|
||||
new(
|
||||
title: user_message.first(20),
|
||||
messages: [
|
||||
Message.new(role: "developer", content: developer_prompt),
|
||||
Message.new(role: "user", content: user_message)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def developer_prompt
|
||||
<<~PROMPT
|
||||
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:
|
||||
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.
|
||||
|
||||
DO NOT:
|
||||
- Add introductions or conclusions
|
||||
- Apologize or explain limitations
|
||||
|
||||
Present monetary values using the format provided by the functions.
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ class Message < ApplicationRecord
|
||||
debug: "debug" # internal only, never sent to OpenAI
|
||||
}
|
||||
|
||||
validates :content, presence: true, allow_blank: true
|
||||
validates :content, presence: true
|
||||
|
||||
after_create_commit :broadcast_and_fetch
|
||||
after_update_commit -> { broadcast_update_to chat }
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class="w-18 h-18 flex-shrink-0 -mr-1 -ml-2 -mt-1">
|
||||
<div class="w-16 h-16 flex-shrink-0 -ml-3 -mt-3">
|
||||
<%= image_tag "ai.svg", alt: "AI", class: "w-full h-full" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,28 +1,39 @@
|
||||
<div class="flex items-start gap-1 mt-4 w-full">
|
||||
<div class="flex items-start gap-2 w-full">
|
||||
<%= render "chats/ai_avatar" %>
|
||||
|
||||
<div class="pt-2 pr-1 max-w-[85%] text-gray-800 text-sm">
|
||||
<div class="max-w-[85%] text-sm space-y-4 text-primary">
|
||||
<p>Hey <%= Current.user&.first_name || 'there' %>! I'm an AI built by Maybe to help with your finances. I have access to the web and your account data.</p>
|
||||
|
||||
<div class="mt-4 text-gray-600">
|
||||
<p>
|
||||
You can use <span class="bg-white border border-gray-200 px-1.5 py-0.5 rounded font-mono text-xs">/</span> to access commands
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="text-gray-600 mb-3">Here's a few questions you can ask:</p>
|
||||
<div class="space-y-3">
|
||||
<p>Here's a few questions you can ask:</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
|
||||
<%= icon("bar-chart-2") %> Evaluate investment portfolio
|
||||
</button>
|
||||
<% questions = [
|
||||
{
|
||||
icon: "bar-chart-2",
|
||||
text: "Evaluate investment portfolio"
|
||||
},
|
||||
{
|
||||
icon: "credit-card",
|
||||
text: "Show spending insights"
|
||||
},
|
||||
{
|
||||
icon: "alert-triangle",
|
||||
text: "Find unusual patterns"
|
||||
}
|
||||
] %>
|
||||
|
||||
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
|
||||
<%= icon("credit-card") %> Show spending insights
|
||||
</button>
|
||||
|
||||
<button class="w-full flex items-center gap-2 bg-white border border-gray-200 rounded-full py-2 px-4 text-left hover:bg-gray-50">
|
||||
<%= icon("alert-triangle") %> Find unusual patterns
|
||||
</button>
|
||||
<div class="space-y-2.5">
|
||||
<% questions.each do |question| %>
|
||||
<button data-action="chat#submitSampleQuestion"
|
||||
data-chat-question-param="<%= question[:text] %>"
|
||||
class="w-full flex items-center gap-2 border border-tertiary rounded-full py-1.5 px-2.5 hover:bg-gray-100">
|
||||
<%= icon(question[:icon]) %> <%= question[:text] %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<%= tag.div class: "flex items-center justify-between px-4 py-3 bg-container shadow-border-xs rounded-lg" do %>
|
||||
<div class="grow">
|
||||
<%= render "chats/chat_title", chat: chat %>
|
||||
<%= render "chats/chat_title", chat: chat, ctx: "list" %>
|
||||
|
||||
<p class="text-sm text-secondary">
|
||||
<%= time_ago_in_words(chat.updated_at) %> ago
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<%= contextual_menu icon: "more-vertical" do %>
|
||||
<%= contextual_menu_modal_action_item("Edit chat", edit_chat_path(chat), turbo_frame: dom_id(chat, :title)) %>
|
||||
<%= contextual_menu_item("Edit chat", url: edit_chat_path(chat), icon: "pencil", turbo_frame: dom_id(chat, :title)) %>
|
||||
<%= contextual_menu_destructive_item("Delete chat", chat_path(chat)) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -4,7 +4,7 @@
|
||||
<% path = chat.new_record? ? chats_path : chats_path(previous_chat_id: chat.id) %>
|
||||
|
||||
<div class="flex items-center gap-2 grow">
|
||||
<%= link_to path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover", data: { turbo_frame: "chat" } do %>
|
||||
<%= link_to path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
|
||||
<%= icon("menu", color: "gray" ) %>
|
||||
<% end %>
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
</div>
|
||||
|
||||
<%= contextual_menu icon: "more-vertical" do %>
|
||||
<%= contextual_menu_modal_action_item "Start new chat", new_chat_path, icon: "plus", turbo_frame: "sidebar-chat-content" %>
|
||||
<%= contextual_menu_item "Start new chat", url: new_chat_path, icon: "plus" %>
|
||||
|
||||
<% unless chat.new_record? %>
|
||||
<%= contextual_menu_modal_action_item "Edit chat title", edit_chat_path(chat, ctx: "chat"), icon: "pencil", turbo_frame: dom_id(chat, "title") %>
|
||||
<%= contextual_menu_item "Edit chat title", url: edit_chat_path(chat, ctx: "chat"), icon: "pencil", turbo_frame: dom_id(chat, "title") %>
|
||||
<%= contextual_menu_destructive_item "Delete chat", chat_path(chat), turbo_confirm: "Are you sure you want to delete this chat?" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<% if chat.new_record? || ctx == "chat" %>
|
||||
<h3 class="text-sm font-medium text-primary"><%= chat.title || "New chat" %></h3>
|
||||
<% else %>
|
||||
<%= link_to chat_path(chat), data: { turbo_frame: "sidebar-chat-content" } do %>
|
||||
<%= link_to chat_path(chat), data: { turbo_frame: chat_frame } do %>
|
||||
<h3 class="text-sm font-medium text-primary"><%= chat.title %></h3>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<%= turbo_stream.replace "messages" do %>
|
||||
<div id="messages" data-turbo-cache="false">
|
||||
<%= render "chats/ai_greeting", context: 'chat' %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,27 +1,31 @@
|
||||
<%= turbo_frame_tag "sidebar-chat-content" do %>
|
||||
<nav class="mb-6">
|
||||
<% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
|
||||
<%= link_to back_path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover", data: { turbo_frame: "sidebar-chat-content" } do %>
|
||||
<%= icon("arrow-left", color: "gray" ) %>
|
||||
<% end %>
|
||||
</nav>
|
||||
<%= turbo_frame_tag chat_frame do %>
|
||||
<div class="p-4 flex flex-col h-full">
|
||||
<nav class="mb-6">
|
||||
<% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
|
||||
<%= link_to back_path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
|
||||
<%= icon("arrow-left", color: "gray" ) %>
|
||||
<% end %>
|
||||
</nav>
|
||||
|
||||
<h1 class="text-xl font-medium mb-6">Chats</h1>
|
||||
<div class="grow">
|
||||
<h1 class="text-xl font-medium mb-6">Chats</h1>
|
||||
|
||||
<div>
|
||||
<% if @chats.any? %>
|
||||
<div class="space-y-2">
|
||||
<%= render @chats %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12 bg-white rounded-lg border border-gray-200">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<%= icon("message-square", size: "lg") %>
|
||||
<% if @chats.any? %>
|
||||
<div class="space-y-2 px-0.5">
|
||||
<%= render @chats %>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-1">No chats yet</h3>
|
||||
<p class="text-gray-500 mb-4">Start a new conversation with the AI assistant</p>
|
||||
<%= link_to "Start a chat", new_chat_path, data: { turbo_frame: "sidebar-chat-content" }, class: "inline-flex items-center gap-2 py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-center py-12 bg-white rounded-lg border border-gray-200">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<%= icon("message-square", size: "lg") %>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-1">No chats yet</h3>
|
||||
<p class="text-gray-500 mb-4">Start a new conversation with the AI assistant</p>
|
||||
<%= link_to "Start a chat", new_chat_path, class: "inline-flex items-center gap-2 py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render "messages/chat_form" %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,9 +1,11 @@
|
||||
<%= turbo_frame_tag "sidebar-chat-content" do %>
|
||||
<div class="flex flex-col h-full">
|
||||
<%= turbo_frame_tag chat_frame do %>
|
||||
<div class="p-4 flex flex-col h-full">
|
||||
<%= render "chats/chat_nav", chat: @chat %>
|
||||
|
||||
<div class="mt-auto py-8">
|
||||
<%= render "chats/ai_greeting" %>
|
||||
</div>
|
||||
|
||||
<%= render "messages/chat_form", chat: @chat %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,19 +1,29 @@
|
||||
<%= turbo_frame_tag "sidebar-chat-content" do %>
|
||||
<%= turbo_stream_from @chat %>
|
||||
|
||||
<%= turbo_frame_tag chat_frame do %>
|
||||
<div class="flex flex-col h-full">
|
||||
<%= render "chats/chat_nav", chat: @chat %>
|
||||
<div class="p-4">
|
||||
<%= render "chats/chat_nav", chat: @chat %>
|
||||
</div>
|
||||
|
||||
<div id="messages" class="grow flex flex-col gap-8 py-8" data-chat-scroll-target="messages">
|
||||
<% if @messages.any? %>
|
||||
<% @messages.each do |message| %>
|
||||
<%= render "messages/message", message: message %>
|
||||
<div id="<%= dom_id(@chat, :messages) %>" class="grow py-8 overflow-y-auto" data-chat-target="messages">
|
||||
<div class="p-4 space-y-6">
|
||||
<% if @chat.messages.conversation.any? %>
|
||||
<% @chat.messages.conversation.ordered.each do |message| %>
|
||||
<%= render "messages/message", message: message %>
|
||||
<% end %>
|
||||
|
||||
<% if params[:thinking] %>
|
||||
<%= render "messages/thinking_message" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="mt-auto">
|
||||
<%= render "chats/ai_greeting", context: 'chat' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="mt-auto">
|
||||
<%= render "chats/ai_greeting", context: 'chat' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<%= render "messages/chat_form", chat: @chat %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,19 +0,0 @@
|
||||
<%= turbo_stream.update "chat_messages" do %>
|
||||
<% @messages.each do |message| %>
|
||||
<div class="flex items-start gap-3 <%= message.user? ? 'justify-end' : '' %> mb-4">
|
||||
<% unless message.user? %>
|
||||
<%= render "chats/ai_avatar" %>
|
||||
<% end %>
|
||||
|
||||
<div class="<%= message.user? ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-800' %> rounded-lg p-4 max-w-[85%]">
|
||||
<p><%= message.content %></p>
|
||||
</div>
|
||||
|
||||
<% if message.user? %>
|
||||
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center flex-shrink-0 text-gray-700">
|
||||
<%= Current.user.initials %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -71,19 +71,16 @@
|
||||
<% end %>
|
||||
|
||||
<%# AI chat sidebar %>
|
||||
<%= tag.div id: "sidebar-chat-container", class: class_names("flex flex-col p-4 pl-0 justify-between shrink-0 transition-all duration-300", right_sidebar_open ? "w-[375px]" : "w-0"), data: { sidebar_target: "rightPanel", turbo_permanent: true } do %>
|
||||
<div class="grow overflow-y-auto">
|
||||
<%= turbo_frame_tag "sidebar-chat-content", src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 shrink-0">
|
||||
<%= render "messages/form" %>
|
||||
<p class="text-xs text-secondary">AI responses are informational only and are not financial advice.</p>
|
||||
</div>
|
||||
<%= tag.div id: "chat-container",
|
||||
class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300", right_sidebar_open ? "w-[400px]" : "w-0"),
|
||||
data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %>
|
||||
<%# All chat broadcasts are sent to this centralized user-level stream %>
|
||||
<%= turbo_stream_from Current.user, "chat" %>
|
||||
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
32
app/views/messages/_chat_form.html.erb
Normal file
32
app/views/messages/_chat_form.html.erb
Normal file
@@ -0,0 +1,32 @@
|
||||
<%# locals: (chat: nil, message_hint: nil) %>
|
||||
|
||||
<div id="chat-form" class="space-y-2">
|
||||
<% model = chat && chat.persisted? ? [chat, Message.new] : Chat.new %>
|
||||
|
||||
<%= form_with model: model,
|
||||
class: "flex flex-col gap-2 bg-white px-2 py-1.5 rounded-lg shadow-border-xs",
|
||||
data: { chat_target: "form" } do |f| %>
|
||||
|
||||
<%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint,
|
||||
class: "w-full border-0 focus:ring-0 text-sm resize-none px-1",
|
||||
data: { chat_target: "input", action: "input->chat#autoResize keydown->chat#handleInputKeyDown" },
|
||||
rows: 1 %>
|
||||
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>
|
||||
<% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %>
|
||||
<button type="button" title="Coming soon" class="cursor-not-allowed w-8 h-8 flex justify-center items-center hover:bg-surface-hover rounded-lg">
|
||||
<%= icon(icon, color: "gray") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-8 h-8 flex justify-center items-center text-secondary hover:bg-surface-hover cursor-pointer rounded-lg">
|
||||
<%= icon("arrow-up") %>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="text-xs text-secondary">AI responses are informational only and are not financial advice.</p>
|
||||
</div>
|
||||
26
app/views/messages/_debug_message.html.erb
Normal file
26
app/views/messages/_debug_message.html.erb
Normal file
@@ -0,0 +1,26 @@
|
||||
<%# locals: (content:) %>
|
||||
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 w-full text-gray-700 text-xs font-mono">
|
||||
<div class="flex items-center mb-1">
|
||||
<div class="mr-1"><%= icon("terminal", size: 14) %></div>
|
||||
<div class="font-semibold"><%= content.split("\n").first %></div>
|
||||
</div>
|
||||
<% if content.include?("```json") %>
|
||||
<%
|
||||
json_start = content.index("```json")
|
||||
json_end = content.index("```", json_start + 7)
|
||||
if json_start && json_end
|
||||
json_content = content[(json_start + 7)...json_end].strip
|
||||
begin
|
||||
parsed_json = JSON.parse(json_content)
|
||||
formatted_json = JSON.pretty_generate(parsed_json)
|
||||
rescue
|
||||
formatted_json = json_content
|
||||
end
|
||||
end
|
||||
%>
|
||||
<div class="mt-2 overflow-x-auto">
|
||||
<pre class="text-xs"><%= formatted_json %></pre>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<%# locals: (chat: nil) %>
|
||||
|
||||
<% model = chat ? [chat, Message.new] : Chat.new %>
|
||||
|
||||
<%= form_with model: model, class: "flex flex-col gap-2 bg-white px-2 py-1.5 rounded-lg shadow-border-xs" do |f| %>
|
||||
<%= f.text_area :content,
|
||||
placeholder: "Ask anything ...",
|
||||
class: "w-full border-0 focus:ring-0 text-sm resize-none px-1",
|
||||
rows: 1 %>
|
||||
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>
|
||||
<% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %>
|
||||
<button type="button" class="w-8 h-8 flex justify-center items-center hover:bg-surface-hover rounded-lg">
|
||||
<%= icon(icon, color: "gray") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-8 h-8 flex justify-center items-center text-secondary hover:bg-surface-hover cursor-pointer rounded-lg">
|
||||
<%= icon("arrow-up") %>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,44 +1,18 @@
|
||||
<%
|
||||
is_debug_message = message.role == "system" && message.content.start_with?("🐞 DEBUG:")
|
||||
show_message = (!is_debug_message || ENV["AI_DEBUG_MODE"] == "true")
|
||||
%>
|
||||
<%# locals: (message:) %>
|
||||
|
||||
<% if show_message %>
|
||||
<div id="<%= dom_id(message) %>" class="flex items-start gap-3 <%= message.user? ? 'justify-end' : (is_debug_message ? 'justify-start debug-message' : '') %> mb-4 w-full">
|
||||
<% if is_debug_message %>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 w-full text-gray-700 text-xs font-mono">
|
||||
<div class="flex items-center mb-1">
|
||||
<div class="mr-1"><%= icon("terminal", size: 14) %></div>
|
||||
<div class="font-semibold"><%= message.content.split("\n").first %></div>
|
||||
</div>
|
||||
<% if message.content.include?("```json") %>
|
||||
<%
|
||||
json_start = message.content.index("```json")
|
||||
json_end = message.content.index("```", json_start + 7)
|
||||
if json_start && json_end
|
||||
json_content = message.content[(json_start + 7)...json_end].strip
|
||||
begin
|
||||
parsed_json = JSON.parse(json_content)
|
||||
formatted_json = JSON.pretty_generate(parsed_json)
|
||||
rescue
|
||||
formatted_json = json_content
|
||||
end
|
||||
end
|
||||
%>
|
||||
<div class="mt-2 overflow-x-auto">
|
||||
<pre class="text-xs"><%= formatted_json %></pre>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% elsif message.user? %>
|
||||
<div class="bg-gray-100 rounded-lg px-3 py-2 max-w-[85%] text-gray-800">
|
||||
<p class="whitespace-pre-wrap break-words"><%= message.content %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div id="<%= dom_id(message) %>">
|
||||
<% if message.debug? %>
|
||||
<%= render "messages/debug_message", content: message.content %>
|
||||
<% elsif message.user? %>
|
||||
<div class="ml-auto text-sm text-primary bg-gray-100 rounded-lg px-3 py-2 max-w-[85%]">
|
||||
<div class="whitespace-pre-wrap break-words"><%= message.content %></div>
|
||||
</div>
|
||||
<% elsif message.assistant? %>
|
||||
<div class="flex items-start">
|
||||
<%= render "chats/ai_avatar" %>
|
||||
<div class="pr-2 max-w-[85%] text-gray-800">
|
||||
<div class="pr-2 max-w-[85%] text-primary">
|
||||
<div class="prose prose-sm prose-gray break-words"><%= markdown(message.content) %></div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
4
app/views/messages/_thinking_message.html.erb
Normal file
4
app/views/messages/_thinking_message.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div id="thinking-message" class="flex items-start">
|
||||
<%= render "chats/ai_avatar" %>
|
||||
<p class="text-sm text-secondary animate-pulse">Thinking...</p>
|
||||
</div>
|
||||
@@ -1,23 +1,11 @@
|
||||
<%= turbo_stream.append "messages" do %>
|
||||
<%= turbo_stream.append dom_id(@chat, :messages) do %>
|
||||
<%= render "messages/message", message: @message %>
|
||||
<% end %>
|
||||
|
||||
<% if @message.user? %>
|
||||
<%= turbo_stream.replace "thinking" do %>
|
||||
<div id="thinking" class="flex items-start gap-3 mb-4">
|
||||
<%= render "chats/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">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= turbo_stream.append dom_id(@chat, :messages) do %>
|
||||
<%= render "messages/thinking_message" %>
|
||||
<% 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 %>
|
||||
<%= turbo_stream.replace "chat-form" do %>
|
||||
<%= render "messages/chat_form", chat: @chat %>
|
||||
<% end %>
|
||||
Reference in New Issue
Block a user