diff --git a/app/assets/stylesheets/conversation.css b/app/assets/stylesheets/conversation.css new file mode 100644 index 000000000..f6574ce1e --- /dev/null +++ b/app/assets/stylesheets/conversation.css @@ -0,0 +1,89 @@ +@layer components { + .conversation { + --color-border: #fff; + + display: flex; + flex-direction: column; + height: 100%; + margin-bottom: 0; + + .conversation__messages { + display: flex; + flex-direction: column; + gap: 1.6rem; + height: 100%; + margin: 1rem; + overflow-x: hidden; + overflow-y: auto; + } + + .conversation__composer { + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: row; + gap: 1rem; + min-height: 6rem; + padding: 1rem; + + .conversation__composer__submit { + display: flex; + flex-direction: column; + flex-grow: 0; + } + + .conversation__composer__input { + flex-grow: 1; + } + + .conversation__composer__input textarea { + border-radius: 0.5rem; + border: 1px solid var(--color-border); + height: 100%; + padding: 0.5rem; + resize: none; + width: 100%; + } + } + + .conversation__message { + } + + .conversation__message--user { + .action-text-content { + border-left: 1px solid var(--color-border); + border-radius: 0.5rem; + border-top: 1px solid var(--color-border); + margin-left: 15%; + padding: 1rem; + text-align: right; + } + + details { + margin-top: 0.5rem; + + summary { + text-align: right; + } + } + } + + .conversation__message--assistant { + .action-text-content { + padding: 1rem; + text-align: left; + margin-right: 15%; + border-right: 1px solid var(--color-border); + border-top: 1px solid var(--color-border); + border-radius: 0.5rem; + } + + details { + margin-top: 0.5rem; + + summary { + text-align: left; + } + } + } + } +} diff --git a/app/assets/stylesheets/terminals.css b/app/assets/stylesheets/terminals.css index a35fc0dda..b0c6be5d2 100644 --- a/app/assets/stylesheets/terminals.css +++ b/app/assets/stylesheets/terminals.css @@ -240,6 +240,17 @@ } } + .terminal__modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto 15rem; + margin-bottom: 3.55rem; + height: calc(100vh - 15rem); + } + /* Lexical prompt insertions /* ------------------------------------------------------------------------ */ diff --git a/app/controllers/commands_controller.rb b/app/controllers/commands_controller.rb index 65017df7a..060f7ae17 100644 --- a/app/controllers/commands_controller.rb +++ b/app/controllers/commands_controller.rb @@ -45,6 +45,8 @@ class CommandsController < ApplicationController respond_with_composite_response(result) when Command::Result::Redirection redirect_to result.url + when Command::Result::ShowModal + respond_with_turbo_frame_modal(result.turbo_frame, result.url) else redirect_back_or_to root_path end @@ -68,6 +70,10 @@ class CommandsController < ApplicationController render json: { message: chat_response.content }, status: :accepted end + def respond_with_turbo_frame_modal(turbo_frame, url) + render json: { turbo_frame: turbo_frame, url: url }, status: :accepted + end + def respond_with_error(command) error_message = command.error_messages.map { |msg| "- #{msg}\n" }.join render json: { error: error_message, context_url: command.context.url }, status: :unprocessable_entity diff --git a/app/controllers/conversations/messages_controller.rb b/app/controllers/conversations/messages_controller.rb new file mode 100644 index 000000000..ee0a6ce4d --- /dev/null +++ b/app/controllers/conversations/messages_controller.rb @@ -0,0 +1,11 @@ +class Conversations::MessagesController < ApplicationController + def create + Current.user.resume_or_start_conversation(message_params[:content]) + head :no_content + end + + private + def message_params + params.require(:conversation_message).permit(:content) + end +end diff --git a/app/controllers/conversations_controller.rb b/app/controllers/conversations_controller.rb new file mode 100644 index 000000000..09a7d5264 --- /dev/null +++ b/app/controllers/conversations_controller.rb @@ -0,0 +1,15 @@ +class ConversationsController < ApplicationController + def create + Current.user.resume_or_start_conversation(question_params[:conversation][:question]) + redirect_to conversation_path, status: :see_other + end + + def show + @conversation = Current.user.conversation + end + + private + def conversation_params + params.require(:conversation).permit(:question) + end +end diff --git a/app/controllers/prompts/commands_controller.rb b/app/controllers/prompts/commands_controller.rb index 3f9c75bf9..4073a716c 100644 --- a/app/controllers/prompts/commands_controller.rb +++ b/app/controllers/prompts/commands_controller.rb @@ -13,6 +13,7 @@ class Prompts::CommandsController < ApplicationController [ "/user", "Open user profile", "/user " ], [ "/tag", "Tag selected cards", "/tag #" ], [ "/stage", "Move cards to a Workflow Stage", "/stage " ], + [ "/ask", "Ask a question about cards", "/ask " ], [ "/help", "Show help menu", "/help" ] ] diff --git a/app/javascript/controllers/terminal_controller.js b/app/javascript/controllers/terminal_controller.js index 35681f97c..4a5ccc39d 100644 --- a/app/javascript/controllers/terminal_controller.js +++ b/app/javascript/controllers/terminal_controller.js @@ -5,9 +5,10 @@ import { marked } from "marked" import { nextFrame } from "helpers/timing_helpers"; export default class extends Controller { - static targets = [ "input", "form", "output", "confirmation", "recentCommands" ] + static targets = [ "input", "form", "output", "confirmation", "recentCommands", "modalTurboFrame" ] static classes = [ "error", "confirmation", "help", "output", "busy" ] static values = { originalInput: String, waitingForConfirmation: Boolean } + static outlets = [ "dialog" ] connect() { if (this.waitingForConfirmationValue) { this.focus() } @@ -141,7 +142,7 @@ export default class extends Controller { } #handleJsonResponse(responseJson) { - const { confirmation, message, redirect_to } = responseJson + const { confirmation, message, redirect_to, turbo_frame, url } = responseJson if (message) { this.#showOutput(message) @@ -154,6 +155,10 @@ export default class extends Controller { if (redirect_to) { Turbo.visit(redirect_to) } + + if (turbo_frame && url) { + this.#showTurboFrameModal(turbo_frame, url) + } } async #requestConfirmation(confirmationPrompt) { @@ -204,4 +209,11 @@ export default class extends Controller { #hideOutput(html) { this.element.classList.remove(this.outputClass) } + + #showTurboFrameModal(name, url) { + this.inputTarget.blur() + this.modalTurboFrameTarget.id = name + this.modalTurboFrameTarget.src = url + this.dialogOutlet.open() + } } diff --git a/app/jobs/conversation/response_generator_job.rb b/app/jobs/conversation/response_generator_job.rb new file mode 100644 index 000000000..5f2ce7f7d --- /dev/null +++ b/app/jobs/conversation/response_generator_job.rb @@ -0,0 +1,5 @@ +class Conversation::ResponseGeneratorJob < ApplicationJob + def perform(message) + message.generate_response + end +end diff --git a/app/models/ai/tool/list_cards.rb b/app/models/ai/tool/list_cards.rb new file mode 100644 index 000000000..cae800912 --- /dev/null +++ b/app/models/ai/tool/list_cards.rb @@ -0,0 +1,117 @@ +class Ai::Tool::ListCards < RubyLLM::Tool + description <<-MD + Lists all cards accessible by the current user. + The response is paginated so you may need to iterate through multiple pages to get the full list. + A next page exists if the `pagination.next_page` field is present in the response. + Responses are JSON objects that look like this: + ``` + { + "cards": [ + { "id": 3 }, + { "id": 4 } + ], + "pagination": { + "next_page": "e3c2gh75e4..." + } + } + ``` + Each collection object has the following fields: + - id [Integer, not null] + - title [String, not null] - The title of the card + - status [String, not null] - Enum of "creating", "draft" and "published" + - last_active_at [DateTime, not null] - The last time the card was updated + - collection_id [Integer, not null] - The ID of the collection this card belongs to + - stage [Object, not null] - The stage this card is in, with fields: + - id [Integer, not null] + - name [String, not null] + - creator [Object, not null] - The user who created the card, with fields: + - id [Integer, not null] + - name [String, not null] + - assignees [Array of Objects, not null] - The users assigned to the card, each with fields: + - id [Integer, not null] + - name [String, not null] + MD + + param :query, + type: :string, + desc: "If provided, will perform a semantinc search by embeddings and return only matching cards", + required: false + param :page, + type: :string, + desc: "Which page to return. Leave balnk to get the first page", + required: false + param :collection_id, + type: :integer, + desc: "If provided, will return only cards for the specified collection", + required: false + param :golden, + type: :boolean, + desc: "If provided, will return only golden cards", + required: false + param :created_at_gte, + type: :string, + desc: "If provided, will return only card created on or after after the given ISO timestamp", + required: false + param :created_at_lte, + type: :string, + desc: "If provided, will return only card created on or before the given ISO timestamp", + required: false + param :last_active_at_gte, + type: :string, + desc: "If provided, will return only card that were last active on or after after the given ISO timestamp", + required: false + param :last_active_at_lte, + type: :string, + desc: "If provided, will return only card that were last active on or before the given ISO timestamp", + required: false + + def execute(**params) + cards = Card.all.includes(:stage, :creator, :assignees, :goldness) + + cards = cards.search(params[:query]) if params[:query].present? + cards = cards.golden if params[:golden].present? + cards = cards.where(collection_id: params[:collection_id]) if params[:collection_id].present? + + if params[:last_active_at_gte].present? + timestamp = Time.iso8601(params[:last_active_at_gte]) + cards = cards.where(last_active_at: timestamp..) + end + + if params[:last_active_at_lte].present? + timestamp = Time.iso8601(params[:last_active_at_lte]) + cards = cards.where(last_active_at: ..timestamp) + end + + if params[:created_at_gte].present? + timestamp = Time.iso8601(params[:created_at_gte]) + cards = cards.where(created_at: timestamp..) + end + + if params[:created_at_lte].present? + timestamp = Time.iso8601(params[:created_at_lte]) + cards = cards.where(created_at: ..timestamp) + end + + page = GearedPagination::Recordset.new(cards, ordered_by: { id: :desc }).page(page) + + { + cards: page.records.map do |card| + { + id: card.id, + title: card.title, + status: card.status, + last_active_at: card.last_active_at, + collection_id: card.collection_id, + golden: card.golden?, + stage: card.stage.as_json(only: [ :id, :name ]), + creator: card.creator.as_json(only: [ :id, :name ]), + assignees: card.assignees.as_json(only: [ :id, :name ]), + description: card.description.to_plain_text.truncate(1000) + } + end, + pagination: { + next_page: page.next_param + } + }.to_json + end +end diff --git a/app/models/ai/tool/list_collections.rb b/app/models/ai/tool/list_collections.rb new file mode 100644 index 000000000..73fff94d7 --- /dev/null +++ b/app/models/ai/tool/list_collections.rb @@ -0,0 +1,47 @@ +class Ai::Tool::ListCollections < RubyLLM::Tool + description <<-MD + Lists all collections accessible by the current user. + The response is paginated so you may need to iterate through multiple pages to get the full list. + Responses are JSON objects that look like this: + ``` + { + "collections": [ + { "id": 3, "name": "Foo" }, + { "id": 4, "name": "Bar" } + ], + "pagination": { + "next_page": "e3c2gh75e4..." + } + } + ``` + Each collection object has the following fields: + - id [Integer, not null] + - name [String, not null] + MD + + param :page, + type: :string, + desc: "Which page to return. Leave balnk to get the first page", + required: false + + def execute(page: nil) + puts "= TOOL CALL: ListCollections" + + page = GearedPagination::Recordset.new( + Collection.all, + ordered_by: { name: :asc, id: :desc } + ).page(page) + + { + collections: page.records.map do |collection| + { + id: collection.id, + name: collection.name + } + end, + pagination: { + next_page: page.next_param + } + }.to_json + end +end diff --git a/app/models/ai/tool/list_comments.rb b/app/models/ai/tool/list_comments.rb new file mode 100644 index 000000000..04c703e3b --- /dev/null +++ b/app/models/ai/tool/list_comments.rb @@ -0,0 +1,61 @@ +class Ai::Tool::ListComments < RubyLLM::Tool + description <<-MD + Lists all comments accessible by the current user. + The response is paginated so you may need to iterate through multiple pages to get the full list. + Responses are JSON objects that look like this: + ``` + { + "collections": [ + { + "id": 3, + "card_id": 5, + "body": "This is a comment", + "created_at": "2023-10-01T12:00:00Z", + "creator": { "id": 1, "name": "John Doe" }, + "reactions": [ + { "content": "👍", "reacter": { "id": 2, "name": "Jane Doe" } } + ] + ], + "pagination": { + "next_page": "e3c2gh75e4..." + } + } + ``` + Each collection object has the following fields: + - id [Integer, not null] + - name [String, not null] + MD + + param :page, + type: :string, + desc: "Which page to return. Leave balnk to get the first page", + required: false + + def execute(page: nil) + page = GearedPagination::Recordset.new( + Comment.all.preload(:card, :creator, reactions: [ :reacter ]), + ordered_by: { created_at: :asc, id: :desc } + ).page(page) + + { + collections: page.records.map do |comment| + { + id: comment.id, + card_id: comment.card_id, + body: comment.body.to_plain_text, + created_at: comment.created_at.iso8601, + creator: comment.creator.as_json(only: [ :id, :name ]), + reactions: comment.reactions.map do |reaction| + { + content: reaction.content, + reacter: reaction.reacter.as_json(only: [ :id, :name ]) + } + end + } + end, + pagination: { + next_page: page.next_param + } + }.to_json + end +end diff --git a/app/models/ai/tool/list_users.rb b/app/models/ai/tool/list_users.rb new file mode 100644 index 000000000..0c4fc3e85 --- /dev/null +++ b/app/models/ai/tool/list_users.rb @@ -0,0 +1,45 @@ +class Ai::Tool::ListUsers < RubyLLM::Tool + description <<-MD + Lists all users accessible by the current user. + The response is paginated so you may need to iterate through multiple pages to get the full list. + Responses are JSON objects that look like this: + ``` + { + "collections": [ + { "id": 3, "name": "John Doe" }, + { "id": 4, "name": "Johanna Doe" } + ], + "pagination": { + "next_page": "e3c2gh75e4..." + } + } + ``` + Each collection object has the following fields: + - id [Integer, not null] + - name [String, not null] + MD + + param :page, + type: :string, + desc: "Which page to return. Leave balnk to get the first page", + required: false + + def execute(page: nil) + page = GearedPagination::Recordset.new( + User.all, + ordered_by: { name: :asc, id: :desc } + ).page(page) + + { + collections: page.records.map do |user| + { + id: user.id, + name: user.name + } + end, + pagination: { + next_page: page.next_param + } + }.to_json + end +end diff --git a/app/models/command/ask.rb b/app/models/command/ask.rb new file mode 100644 index 000000000..8712c5bb4 --- /dev/null +++ b/app/models/command/ask.rb @@ -0,0 +1,12 @@ +class Command::Ask < Command + store_accessor :data, :question, :card_ids + + def title + "Ask '#{question}'" + end + + def execute + Current.user.resume_or_start_conversation(question) + Command::Result::ShowModal.new(turbo_frame: "conversation", url: conversation_path) + end +end diff --git a/app/models/command/parser.rb b/app/models/command/parser.rb index 3a1ca9f71..cba13ded4 100644 --- a/app/models/command/parser.rb +++ b/app/models/command/parser.rb @@ -67,6 +67,8 @@ class Command::Parser Command::VisitUrl.new(url: command_arguments.first) when "/tag" Command::Tag.new(tag_title: tag_title_from(combined_arguments), card_ids: cards.ids) + when "/ask" + Command::Ask.new(question: combined_arguments, card_ids: cards.ids) when /^gid:\/\// parse_gid command_name else diff --git a/app/models/command/result/show_modal.rb b/app/models/command/result/show_modal.rb new file mode 100644 index 000000000..19922ab5f --- /dev/null +++ b/app/models/command/result/show_modal.rb @@ -0,0 +1,12 @@ +class Command::Result::ShowModal + attr_reader :url, :turbo_frame + + def initialize(url:, turbo_frame:) + @url = url + @turbo_frame = turbo_frame + end + + def as_json + { turbo_frame: turbo_frame, url: url } + end +end diff --git a/app/models/conversation.rb b/app/models/conversation.rb new file mode 100644 index 000000000..1bdc4ca13 --- /dev/null +++ b/app/models/conversation.rb @@ -0,0 +1,39 @@ +class Conversation < ApplicationRecord + broadcasts_refreshes + + belongs_to :user + has_many :messages, dependent: :destroy + + enum :state, %w[ ready thinking ].index_by(&:itself), default: :ready + + def price + messages.pluck(:price_microcents).compact.sum.to_d / 100_000 + end + + def clear + messages.delete_all + touch + end + + def ask(question) + raise ArgumentError, "Question cannot be blank" if question.blank? + + with_lock do + return false if thinking? + + thinking! + messages.create!(role: :user, content: question) + end + end + + def respond(answer, **attributes) + raise ArgumentError, "Answer cannot be blank" if answer.blank? + + with_lock do + return false unless thinking? + + messages.create!(**attributes, role: :assistant, content: answer) + ready! + end + end +end diff --git a/app/models/conversation/message.rb b/app/models/conversation/message.rb new file mode 100644 index 000000000..1d5db9748 --- /dev/null +++ b/app/models/conversation/message.rb @@ -0,0 +1,40 @@ +class Conversation::Message < ApplicationRecord + has_rich_text :content + + belongs_to :conversation, inverse_of: :messages + + enum :role, %w[ user assistant ].index_by(&:itself) + + after_create_commit :generate_response_later, if: :user? + + def generate_response_later + Conversation::ResponseGeneratorJob.perform_later(self) + end + + def generate_response + response = Conversation::ResponseGenerator.new(self).generate + + message_attributes = { + model_id: response.model_id, + input_tokens: response.input_tokens, + output_tokens: response.output_tokens, + input_cost_microcents: response.input_cost_microcents, + output_cost_microcents: response.output_cost_microcents, + cost_microcents: response.cost_microcents + } + + conversation.respond(response.answer, **message_attributes) + end + + def to_llm + RubyLLM::Message.new( + role: role.to_sym, + content: content.to_plain_text, + tool_calls: nil, + tool_call_id: nil, + input_tokens: input_tokens, + output_tokens: output_tokens, + model_id: model_id + ) + end +end diff --git a/app/models/conversation/response_generator.rb b/app/models/conversation/response_generator.rb new file mode 100644 index 000000000..651ccdb6d --- /dev/null +++ b/app/models/conversation/response_generator.rb @@ -0,0 +1,88 @@ +class Conversation::ResponseGenerator + SYSTEM_PROMPT = <<~PROMPT + You are **Fizzy**, a helpful assistant for the Fizzy app by 37signals. + Fizz helps users track projects, manage tasks, and collaborate with their teams. + + ### 🗂 App Structure + - An account can have multiple **collections** + - Each collection contains **cards** + - Cards go through various **stages** and have a **creator** and one or more **assignees** + + ### 🧠 Your Role + You help users with anything related to their Fizz data — tasks, projects, trends, and team activity. + + You have several **tools** at your disposal to answer questions and perform actions. Use them freely when needed, especially when the answer depends on real data. + + ### ✅ Guidelines + - Be **concise**, **accurate**, and **friendly** + - Speak naturally — no corporate tone or robotic phrasing + - **Never suggest follow-up questions, extra details, or further actions** unless the user explicitly asks + - Do **not** include phrases like “If you want more…” or “Let me know if…” — just answer the question as asked + - Stick strictly to the user's intent — no speculation, hedging, or filler + - If you're unsure what they mean, ask a clarifying question — but only if you truly cannot infer it from context + - Always assume questions are about **their own Fizz data** — cards, collections, or team activity + - If a question isn’t related to Fizz, respond politely with “I don’t know” or “I’m not sure” and explain that you can only answer questions related to Fizzy + - Don’t explain concepts or go off-topic — answer only what was asked + - Respond in **Markdown** + + When in doubt, examine their cards, collections, or team activity to figure out the answer. + + You're here to help — not to anticipate. + PROMPT + + attr_reader :message + + delegate :conversation, to: :message + + def initialize(message) + @message = message + end + + def generate + response = llm.ask(message.content.to_plain_text) + answer = markdown_to_html(response.content) + + Response.new( + answer: answer, + input_tokens: response.input_tokens, + output_tokens: response.output_tokens, + model_id: response.model_id + ) + end + + private + def llm + RubyLLM.chat.tap do |chat| + chat.with_tool(Ai::Tool::ListCards.new) + chat.with_tool(Ai::Tool::ListCollections.new) + chat.with_tool(Ai::Tool::ListComments.new) + chat.with_tool(Ai::Tool::ListUsers.new) + + chat.reset_messages! + + previous_messages.each do |message| + chat.add_message(message.to_llm) + end + + chat.with_instructions(instructions) + end + end + + def previous_messages + conversation.messages.order(id: :asc).where(id: ...message.id).limit(50).with_rich_text_content + end + + def instructions + [ SYSTEM_PROMPT, user_context ].compact.join("\n\n").strip + end + + def user_context + "You are talking to “#{conversation.user.name}”, their Fizzy User ID is #{conversation.user.id}" + end + + def markdown_to_html(markdown) + renderer = Redcarpet::Render::HTML.new + markdowner = Redcarpet::Markdown.new(renderer, autolink: true, tables: true, fenced_code_blocks: true, strikethrough: true, superscript: true) + markdowner.render(markdown).html_safe + end +end diff --git a/app/models/conversation/response_generator/response.rb b/app/models/conversation/response_generator/response.rb new file mode 100644 index 000000000..bd9b4cf16 --- /dev/null +++ b/app/models/conversation/response_generator/response.rb @@ -0,0 +1,54 @@ +class Conversation::ResponseGenerator::Response + MICROCENTS_PER_DOLLAR = 100_000 + + attr_reader :answer, :input_tokens, :output_tokens, :model_id, :tool_calls, :tool_call_id + + def initialize(answer:, input_tokens:, output_tokens:, model_id:) + @answer = answer + @input_tokens = input_tokens + @output_tokens = output_tokens + @model_id = model_id + end + + def cost + cost_microcents.to_d / MICROCENTS_PER_DOLLAR + end + + def cost_microcents + input_cost_microcents + output_cost_microcents + end + + def input_cost_microcents + return unless token_price = input_token_price_microcents + + (input_tokens * token_price).to_i + end + + def input_token_price_microcents + return unless model_info + + price_per_million_tokens_to_microcents(model_info.input_price_per_million) + end + + def output_cost_microcents + return unless token_price = output_token_price_microcents + + (output_tokens * token_price).to_i + end + + def output_token_price_microcents + return unless model_info + + price_per_million_tokens_to_microcents(model_info.output_price_per_million) + end + + def model_info + @model_info ||= RubyLLM.models.find(model_id) + end + + private + def price_per_million_tokens_to_microcents(price) + single_token_price = price.to_d / 1_000_000 + single_token_price * MICROCENTS_PER_DOLLAR + end +end diff --git a/app/models/user.rb b/app/models/user.rb index dcc3ba9b8..bd8cf990d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,6 @@ class User < ApplicationRecord include Accessor, Attachable, Assignee, Mentionable, Named, Role, Searcher, - SignalUser, Staff, Transferable + SignalUser, Staff, Transferable, Conversational include Timelined # Depends on Accessor has_one_attached :avatar diff --git a/app/models/user/conversational.rb b/app/models/user/conversational.rb new file mode 100644 index 000000000..503351652 --- /dev/null +++ b/app/models/user/conversational.rb @@ -0,0 +1,13 @@ +module User::Conversational + extend ActiveSupport::Concern + + included do + has_one :conversation, dependent: :destroy + end + + def resume_or_start_conversation(question = nil) + (conversation || create_conversation).tap do |conversation| + conversation.ask(question) if question.present? + end + end +end diff --git a/app/views/commands/_terminal.html.erb b/app/views/commands/_terminal.html.erb index b649e7c1e..444fab807 100644 --- a/app/views/commands/_terminal.html.erb +++ b/app/views/commands/_terminal.html.erb @@ -1,8 +1,11 @@ +<%= turbo_stream_from Current.user.resume_or_start_conversation %> + <%= tag.div \ id: "command-terminal", class: "terminal full-width flex flex-column justify-end", data: { controller: "toggle-class terminal navigable-list", + terminal_dialog_outlet: "#terminal-modal", terminal_error_class: "terminal--error", terminal_confirmation_class: "terminal--confirmation", terminal_help_class: "terminal--showing-help", @@ -13,6 +16,16 @@ action: "keydown->terminal#handleKeyPress keydown->navigable-list#navigate focusin->navigable-list#select keydown.esc->toggle-class#remove:stop keydown.esc->terminal#focus keydown.esc->navigable-list#selectLast keydown.esc@document->terminal#hideMenus" } do %> <%= turbo_frame_tag :recent_commands, src: commands_path, data: { terminal_target: "recentCommands" } %>
+ <%= tag.dialog \ + id: "terminal-modal", + class: "terminal__modal", + data: { + controller: "dialog", + dialog_target: "dialog", + dialog_modal_value: "true", + action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" } do %> + <%= turbo_frame_tag nil, refresh: :morph, data: { terminal_target: "modalTurboFrame" } %> + <% end %> <%= render "commands/form" %> <% end %> diff --git a/app/views/conversations/_message.html.erb b/app/views/conversations/_message.html.erb new file mode 100644 index 000000000..5cd354b42 --- /dev/null +++ b/app/views/conversations/_message.html.erb @@ -0,0 +1,5 @@ +
+
+ <%= message.content %> +
+
diff --git a/app/views/conversations/show.html.erb b/app/views/conversations/show.html.erb new file mode 100644 index 000000000..f9dd6b39b --- /dev/null +++ b/app/views/conversations/show.html.erb @@ -0,0 +1,19 @@ +<%= turbo_stream_from @conversation %> + +<%= turbo_frame_tag "conversation", target: "_top" do %> +
+
+ <%= render collection: @conversation.messages, partial: "conversations/message" %> +
+ + <%= form_with(model: Conversation::Message.new, local: true, html: { class: "conversation__composer" }) do |form| %> +
+ <%= form.text_area :content, placeholder: "Type your message here...", rows: 3, class: "form-control" %> +
+
+ <%= form.submit "Send", class: "btn" %> +
+ <% end %> +
+<% end %> + diff --git a/config/routes.rb b/config/routes.rb index 9ea3825a8..4ee497259 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -115,6 +115,12 @@ Rails.application.routes.draw do end end + resource :conversation, only: %i[ show create ] do + scope module: :conversations do + resource :messages, only: %i[ create ] + end + end + namespace :my do resources :pins end @@ -175,6 +181,10 @@ Rails.application.routes.draw do polymorphic_path(event.target, options) end + # resolve "Conversation::Message" do |message, options| + # polymorphic_path([ :conversations, :messages ], options) + # end + # get "up", to: "rails/health#show", as: :rails_health_check get "manifest" => "rails/pwa#manifest", as: :pwa_manifest get "service-worker" => "pwa#service_worker" diff --git a/db/migrate/20250728131423_create_conversations.rb b/db/migrate/20250728131423_create_conversations.rb new file mode 100644 index 000000000..f5c7db613 --- /dev/null +++ b/db/migrate/20250728131423_create_conversations.rb @@ -0,0 +1,10 @@ +class CreateConversations < ActiveRecord::Migration[8.1] + def change + create_table :conversations do |t| + t.belongs_to :user, null: false, foreign_key: true, index: { unique: true } + t.string :state, :string + + t.timestamps + end + end +end diff --git a/db/migrate/20250728131624_create_conversation_messages.rb b/db/migrate/20250728131624_create_conversation_messages.rb new file mode 100644 index 000000000..89771306b --- /dev/null +++ b/db/migrate/20250728131624_create_conversation_messages.rb @@ -0,0 +1,16 @@ +class CreateConversationMessages < ActiveRecord::Migration[8.1] + def change + create_table :conversation_messages do |t| + t.belongs_to :conversation, null: false, foreign_key: true + t.string :role + t.string :model_id + t.bigint :input_tokens + t.bigint :output_tokens + t.bigint :input_cost_microcents + t.bigint :output_cost_microcents + t.bigint :cost_microcents + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b4721da26..7e05051f5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -208,6 +208,30 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_12_195130) do t.index ["card_id"], name: "index_comments_on_card_id" end + create_table "conversation_messages", force: :cascade do |t| + t.string "client_message_id", null: false + t.integer "conversation_id", null: false + t.bigint "cost_microcents" + t.datetime "created_at", null: false + t.bigint "input_cost_microcents" + t.bigint "input_tokens" + t.string "model_id" + t.bigint "output_cost_microcents" + t.bigint "output_tokens" + t.string "role", null: false + t.datetime "updated_at", null: false + t.index ["conversation_id"], name: "index_conversation_messages_on_conversation_id" + end + + create_table "conversations", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "state" + t.string "string" + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["user_id"], name: "index_conversations_on_user_id", unique: true + end + create_table "creators_filters", id: false, force: :cascade do |t| t.integer "creator_id", null: false t.integer "filter_id", null: false @@ -438,6 +462,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_12_195130) do add_foreign_key "commands", "commands", column: "parent_id" add_foreign_key "commands", "users" add_foreign_key "comments", "cards" + add_foreign_key "conversation_messages", "conversations" + add_foreign_key "conversations", "users" add_foreign_key "events", "collections" add_foreign_key "mentions", "users", column: "mentionee_id" add_foreign_key "mentions", "users", column: "mentioner_id" diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 3398114f4..ccbc42f82 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -634,6 +634,125 @@ columns: - *24 - *6 - *9 + conversation_messages: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: client_message_id + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: conversation_id + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: cost_microcents + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - *5 + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: input_cost_microcents + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: input_tokens + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: model_id + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: output_cost_microcents + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: output_tokens + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: role + cast_type: *7 + sql_type_metadata: *8 + 'null': false + default: + default_function: + collation: + comment: + - *9 + conversations: + - *5 + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: state + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: string + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *9 + - *25 creators_filters: - *24 - *19 @@ -1201,6 +1320,8 @@ primary_keys: collections_filters: commands: id comments: id + conversation_messages: id + conversations: id creators_filters: entropy_configurations: id event_activity_summaries: id @@ -1248,6 +1369,8 @@ data_sources: collections_filters: true commands: true comments: true + conversation_messages: true + conversations: true creators_filters: true entropy_configurations: true event_activity_summaries: true @@ -1948,6 +2071,40 @@ indexes: nulls_not_distinct: comment: valid: true + conversation_messages: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: conversation_messages + name: index_conversation_messages_on_conversation_id + unique: false + columns: + - conversation_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + conversations: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: conversations + name: index_conversations_on_user_id + unique: true + columns: + - user_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true creators_filters: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: creators_filters diff --git a/test/fixtures/conversation/messages.yml b/test/fixtures/conversation/messages.yml new file mode 100644 index 000000000..7cdb27dd0 --- /dev/null +++ b/test/fixtures/conversation/messages.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + conversation: one + role: MyString + +two: + conversation: two + role: MyString diff --git a/test/fixtures/conversations.yml b/test/fixtures/conversations.yml new file mode 100644 index 000000000..6dc1ab362 --- /dev/null +++ b/test/fixtures/conversations.yml @@ -0,0 +1,7 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + +two: + user: two diff --git a/test/models/conversation/message_test.rb b/test/models/conversation/message_test.rb new file mode 100644 index 000000000..f233d579b --- /dev/null +++ b/test/models/conversation/message_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Conversation::MessageTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/conversation_test.rb b/test/models/conversation_test.rb new file mode 100644 index 000000000..0c0d00ac7 --- /dev/null +++ b/test/models/conversation_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ConversationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end