diff --git a/assets/css/cart.lit.scss b/assets/css/cart.lit.scss index 8c18564..1ef2ef6 100644 --- a/assets/css/cart.lit.scss +++ b/assets/css/cart.lit.scss @@ -184,14 +184,14 @@ &:hover, &:active, &:focus { - background-color: #2c1760; + background-color: #2c1760; } } &:hover, &:active, &:focus { - background-color: #6042BC; + background-color: #6042BC; } &:focus-visible { @@ -205,15 +205,29 @@ z-index: 2; width: .75rem; height: .75rem; - + [part='spinner-path'] { stroke: hsl(260, 72%, 91%); stroke-linecap: round; animation: dash 1.5s ease-in-out infinite; } - + +} + +[part='message']{ + [part='messaage-tag']{ + font-weight: bold; + color: #007BFF; + font-size: 0.9em; + } + + [part='message-content'] { + margin-left: 20px; + font-size: 0.8em; + } } + @keyframes rotate { 100% { transform: rotate(360deg); diff --git a/assets/js/app.js b/assets/js/app.js index 39ac82a..809899c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -57,6 +57,7 @@ window.liveSocket = liveSocket import './launch-cart-additem'; import './launch-cart'; import './launch-form'; +import './launch-bot'; import './web-hooks'; import './form-emails'; import './launch-recaptcha'; \ No newline at end of file diff --git a/assets/js/launch-bot.ts b/assets/js/launch-bot.ts new file mode 100644 index 0000000..026ecec --- /dev/null +++ b/assets/js/launch-bot.ts @@ -0,0 +1,64 @@ +import { html, LitElement, css } from 'lit' +import { customElement, property, query, state } from 'lit/decorators.js' +import { liveState, liveStateConfig } from 'phx-live-state'; +import cartStyles from '../css/cart.lit.scss'; + + +interface Message { + role: string; + content: string; +} + +@customElement('launch-bot') +@liveState({ + properties: ['conversation'], + events: { + send: ['add_message'] + } +}) + + +export class LaunchFormElement extends LitElement { + static styles = cartStyles; + + @property() + @liveStateConfig('url') + url: string = ''; + + @state() + conversation: Array = []; + + @property({attribute: 'bot-id'}) + botId: string = ''; + + @query('#message-text') + messageText: HTMLInputElement | undefined; + + @liveStateConfig('topic') + get topic() {return `launch_bot:${this.botId}`;} + + sendMessage(ev: Event) { + ev.preventDefault(); + console.log(this.conversation) + this.dispatchEvent(new CustomEvent('add_message', { detail: {text: this.messageText?.value}})); + this.messageText!.value = ''; + } + + render() { + return html ` + +
+ + +
+ `; + } + +} \ No newline at end of file diff --git a/lib/launch_cart/application.ex b/lib/launch_cart/application.ex index e0a05a5..ef6daa6 100644 --- a/lib/launch_cart/application.ex +++ b/lib/launch_cart/application.ex @@ -17,9 +17,11 @@ defmodule LaunchCart.Application do # Start the PubSub system {Phoenix.PubSub, name: LaunchCart.PubSub}, # Start the Endpoint (http/https) - LaunchCartWeb.Endpoint + LaunchCartWeb.Endpoint, # Start a worker by calling: LaunchCart.Worker.start_link(arg) # {LaunchCart.Worker, arg} + + {Task.Supervisor, name: LaunchCart.TaskSupervisor} #no idea what this does tbh Thanks ChatGPT :D ] ++ Application.get_env(:launch_cart, :supervised_processes, [ {Cachex, diff --git a/lib/launch_cart/bot.ex b/lib/launch_cart/bot.ex new file mode 100644 index 0000000..f8c39e0 --- /dev/null +++ b/lib/launch_cart/bot.ex @@ -0,0 +1,112 @@ +example_message = {:ok, +%{ + choices: [ + %{ + "finish_reason" => "stop", + "index" => 0, + "message" => %{ + "content" => "The maximum runway length at Butler County Airport (KHAO) is 5,000 feet.", + "role" => "assistant" + } + } + ], + created: 1684089842, + id: "chatcmpl-7GAzKmrOqljU1X96BRUPVcDceBeVW", + model: "gpt-3.5-turbo-0301", + object: "chat.completion", + usage: %{ + "completion_tokens" => 20, + "prompt_tokens" => 24, + "total_tokens" => 44 + } +}} + + +defmodule LaunchCart.Bot do + require HTTPoison + require Logger + + def stream(messages) do + url = "https://api.openai.com/v1/chat/completions" + body = Jason.encode!(body(messages, true)) + headers = headers() + + Stream.resource( + fn -> HTTPoison.post!(url, body, headers, stream_to: self(), async: :once) end, + &handle_async_response/1, + &close_async_response/1 + ) + end + + defp close_async_response(resp) do + :hackney.stop_async(resp) + end + + defp handle_async_response({:done, resp}) do + {:halt, resp} + end + + defp handle_async_response(%HTTPoison.AsyncResponse{id: id} = resp) do + receive do + %HTTPoison.AsyncStatus{id: ^id, code: code} -> + Logger.info("openai,request,status,#{inspect(code)}") + HTTPoison.stream_next(resp) + {[], resp} + + %HTTPoison.AsyncHeaders{id: ^id, headers: headers} -> + Logger.info("openai,request,headers,#{inspect(headers)}") + HTTPoison.stream_next(resp) + {[], resp} + + %HTTPoison.AsyncChunk{id: ^id, chunk: chunk} -> + HTTPoison.stream_next(resp) + parse_chunk(chunk, resp) + + %HTTPoison.AsyncEnd{id: ^id} -> + {:halt, resp} + end + end + + defp parse_chunk(chunk, resp) do + {chunk, done?} = + chunk + |> String.split("data:") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.reduce({"", false}, fn trimmed, {chunk, is_done?} -> + case Jason.decode(trimmed) do + {:ok, res} -> + IO.inspect(res, label: "openai,response") + delta = List.first(res["choices"])["delta"] + content = Map.get(delta, "content") || "" + {chunk <> content, is_done? or false} + + {:error, %{data: "[DONE]"}} -> + {chunk, is_done? or true} + end + end) + + if done? do + {[chunk], {:done, resp}} + else + {[chunk], resp} + end + end + + defp headers() do + [ + Accept: "application/json", + "Content-Type": "application/json", + Authorization: "Bearer #{System.get_env("OPENAI_API_KEY")}" + ] + end + + defp body(messages, streaming?) do + %{ + model: "gpt-3.5-turbo", + messages: messages, + stream: streaming?, + max_tokens: 1024 + } + end +end diff --git a/lib/launch_cart/stream_handler.ex b/lib/launch_cart/stream_handler.ex new file mode 100644 index 0000000..2fef6b9 --- /dev/null +++ b/lib/launch_cart/stream_handler.ex @@ -0,0 +1,17 @@ +defmodule LaunchCart.StreamHandler do + use GenServer + + def start_link({channel_pid, stream}) do + GenServer.start_link(__MODULE__, {channel_pid, stream}) + end + + @spec init({any, any}) :: {:ok, %{channel_pid: any}} + def init({channel_pid, stream}) do + Task.start_link(fn -> + for chunk <- stream do + send(channel_pid, {:render_response_chunk, chunk}) + end + end) + {:ok, %{channel_pid: channel_pid}} + end +end diff --git a/lib/launch_cart_web/channels/launch_bot_channel.ex b/lib/launch_cart_web/channels/launch_bot_channel.ex new file mode 100644 index 0000000..99f8aa3 --- /dev/null +++ b/lib/launch_cart_web/channels/launch_bot_channel.ex @@ -0,0 +1,62 @@ +defmodule LaunchCartWeb.LaunchBotChannel do + use LiveState.Channel, web_module: LaunchCartWeb + + alias LaunchCart.Bot + + @impl true + def init("launch_bot:" <> bot_id, _params, _socket) do + {:ok, %{bot_id: bot_id, conversation: []}} + end + + # @impl true + # def handle_event("add_message", %{"text" => text}, %{conversation: messages} = state) do + # messages = messages ++ [%{"content" => text, "role" => "user"}] + # case OpenAI.chat_completion(model: "gpt-3.5-turbo", messages: messages) do + # {:ok, %{choices: [%{"message" => message} | _]}} -> + # IO.inspect(messages ++ message) + # {:noreply, %{conversation: messages ++ [message]} } + # {:error, error} -> + # IO.inspect(error) + # {:noreply, state} + # end + # end + + + # @impl true + # def handle_event("add_message", %{"text" => text}, %{conversation: messages} = state) do + # messages = messages ++ [%{"content" => text, "role" => "user"}] + # Bot.stream(messages) + # |> Enum.take(1) + # |> Enum.each(fn message -> + # IO.inspect( messages ++ [%{"content" => message, "role" => "user"}]) + # # push(socket, "new_message", %{"content" => message}) + # {:noreply, %{conversation: messages ++ [%{"content" => message, "role" => "user"}]} } + # end) + # # {:noreply, %{conversation: messages}} + # end + + # @impl true + # def handle_event("add_message", %{"text" => text}, %{conversation: messages} = state) do + # messages = messages ++ [%{"content" => text, "role" => "user"}] + # stream = Bot.stream(messages) + # {:noreply, %{conversation: messages ++ [%{"content" => stream_response(stream), "role" => "user"}]} } + # end + + @impl true + def handle_event("add_message", %{"text" => text}, %{conversation: messages} = state) do + messages = [%{"content" => text, "role" => "user"} | messages] + IO.inspect(Enum.reverse(messages)) + stream = Bot.stream(Enum.reverse(messages)) + messages = [%{"content" => "", "role" => "assistant"} | messages] + {:ok, _pid} = LaunchCart.StreamHandler.start_link({self(), stream}) + + {:noreply, %{conversation: messages}} + end + + @impl true + def handle_message({:render_response_chunk, chunk}, %{conversation: [%{"content" => current_message} | messages ]} = state) do + current_message = "#{current_message}#{chunk}" + IO.inspect(current_message) + {:noreply, %{conversation: [%{"content" => current_message, "role" => "assistant"} | messages]}} + end +end diff --git a/lib/launch_cart_web/channels/user_socket.ex b/lib/launch_cart_web/channels/user_socket.ex index 9397be8..10050d4 100644 --- a/lib/launch_cart_web/channels/user_socket.ex +++ b/lib/launch_cart_web/channels/user_socket.ex @@ -12,6 +12,8 @@ defmodule LaunchCartWeb.UserSocket do channel "launch_form:*", LaunchCartWeb.LaunchFormChannel + channel "launch_bot:*", LaunchCartWeb.LaunchBotChannel + # Socket params are passed from the client and can # be used to verify and authenticate a user. After # verification, you can put default assigns into diff --git a/lib/launch_cart_web/controllers/page_controller.ex b/lib/launch_cart_web/controllers/page_controller.ex index 2b937b9..621bcf1 100644 --- a/lib/launch_cart_web/controllers/page_controller.ex +++ b/lib/launch_cart_web/controllers/page_controller.ex @@ -17,6 +17,11 @@ defmodule LaunchCartWeb.PageController do render(conn, "usage_docs.html") end + def launch_bot(conn, _params) do + url = "#{String.replace(Endpoint.url(), "http:", "ws:")}/socket" + render(conn, "launch_bot.html", url: url) + end + def fake_store(conn, %{"store_id" => store_id}) do store = Stores.get_store!(store_id) url = "#{String.replace(Endpoint.url(), "http:", "ws:")}/socket" diff --git a/lib/launch_cart_web/router.ex b/lib/launch_cart_web/router.ex index ec71921..411ff31 100644 --- a/lib/launch_cart_web/router.ex +++ b/lib/launch_cart_web/router.ex @@ -26,6 +26,7 @@ defmodule LaunchCartWeb.Router do get "/", PageController, :index get "/api_docs", PageController, :api_docs get "/usage_docs", PageController, :usage_docs + get "/launch_bot", PageController, :launch_bot end # Other scopes may use custom stacks. diff --git a/lib/launch_cart_web/templates/page/launch_bot.html.heex b/lib/launch_cart_web/templates/page/launch_bot.html.heex new file mode 100644 index 0000000..9a9c056 --- /dev/null +++ b/lib/launch_cart_web/templates/page/launch_bot.html.heex @@ -0,0 +1,3 @@ +

Launch bot

+ + diff --git a/mix.exs b/mix.exs index 3af35e5..d7bba81 100644 --- a/mix.exs +++ b/mix.exs @@ -60,13 +60,15 @@ defmodule LaunchCart.MixProject do {:swoosh, "~> 1.3"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, - {:httpoison, ">= 0.0.0"}, + {:httpoison, "~> 2.0"}, + {:poison, "~> 5.0"}, {:live_elements, ">= 0.0.0"}, {:wallaby, "~> 0.30.2", git: "https://github.com/launchscout/wallaby.git", branch: "shadow-dom", runtime: false, - only: :test} + only: :test}, + {:openai, "~> 0.5.2"} ] end diff --git a/mix.lock b/mix.lock index e75bfc6..50f81b3 100644 --- a/mix.lock +++ b/mix.lock @@ -27,7 +27,7 @@ "gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, - "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "httpoison": {:hex, :httpoison, "2.1.0", "655fd9a7b0b95ee3e9a3b535cf7ac8e08ef5229bab187fa86ac4208b122d934b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fc455cb4306b43827def4f57299b2d5ac8ac331cb23f517e734a4b78210a160c"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "json_diff": {:hex, :json_diff, "0.1.3", "c80d5ca5416e785867e765e906e9a91b7efc35bfd505af276654d108f4995736", [:mix], [], "hexpm", "a5332e8293e7e9f384d34ea44645d7961334db73739165178fd4a7728d06f7d1"}, @@ -42,6 +42,7 @@ "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "openai": {:hex, :openai, "0.5.2", "752f77f50c0bc80f50ae50900332f61283cd01a925b118aa6982ba5a36992540", [:mix], [{:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "85ff1a52f95fdf068cc6e29d200b8f546bfe86b3fd601f3287f981c9c02769bb"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.7.1", "a029bde19d9c3b559e5c3d06c78b76e81396bedd456a6acedb42f9c7b2e535a9", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ea9d4a85c3592e37efa07d0dc013254fda445885facaefddcbf646375c116457"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, @@ -55,6 +56,7 @@ "plug": {:hex, :plug, "1.14.1", "3148623796853ae96c628960b833bf6b6a894d6bdc8c199ef7160c41149b71f2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a0e789be21a576b11ec55a0983e4e8f7c7b07d88dfb3b8da9e97767132271d40"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, + "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"},