diff --git a/assets/css/app.css b/assets/css/app.css index 5e15d46..d31f03e 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -20,7 +20,7 @@ --color-seafoam-700: hsl(192.92 72.28% 30.98%); --color-seafoam-800: hsl(194.38 59.57% 27.06%); --color-seafoam-900: hsl(198.18 73.33% 17.65%); - --color-seafoam-950: hsl(198.26 58.97% 7.65%); + --color-seafoam-950: hsl(196.88 72.73% 8.63%); } @variant phx-click-loading (&.phx-click-loading, .phx-click-loading &); diff --git a/assets/css/markdown.css b/assets/css/markdown.css index 9857315..a27d461 100644 --- a/assets/css/markdown.css +++ b/assets/css/markdown.css @@ -92,7 +92,7 @@ } .markdown table { - @apply block w-full border-collapse my-4 text-sm overflow-x-auto; + @apply block w-full border-collapse my-4 text-sm overflow-x-auto max-w-full; } .markdown thead { @apply bg-seafoam-950; diff --git a/assets/js/voice_control.js b/assets/js/voice_control.js index 68db971..613cfba 100644 --- a/assets/js/voice_control.js +++ b/assets/js/voice_control.js @@ -30,6 +30,18 @@ const VoiceControl = { // Button clicks dispatch DOM events to avoid a server round-trip this.el.addEventListener("voice:start", () => this.startRecording()); this.el.addEventListener("voice:stop", () => this.stopRecording()); + + // Handle navigate_to from the server — trigger a live navigation so the + // root layout (and this VoiceLive) is preserved across page changes. + this.handleEvent("navigate_to", ({ path }) => { + let a = document.createElement("a"); + a.href = path; + a.setAttribute("data-phx-link", "redirect"); + a.setAttribute("data-phx-link-state", "push"); + document.body.appendChild(a); + a.click(); + a.remove(); + }); }, destroyed() { diff --git a/lib/elixir_ai/ai_utils/stream_line_utils.ex b/lib/elixir_ai/ai_utils/stream_line_utils.ex index d5e8aec..dcc70be 100644 --- a/lib/elixir_ai/ai_utils/stream_line_utils.ex +++ b/lib/elixir_ai/ai_utils/stream_line_utils.ex @@ -135,6 +135,11 @@ defmodule ElixirAi.AiUtils.StreamLineUtils do :ok end + def handle_stream_line(server, "proxy error" <> _ = error) when is_binary(error) do + Logger.error("Proxy error in AI stream: #{error}") + send(server, {:stream, {:ai_request_error, error}}) + end + def handle_stream_line(server, json) when is_binary(json) do case Jason.decode(json) do {:ok, body} -> diff --git a/lib/elixir_ai/chat_runner/chat_runner.ex b/lib/elixir_ai/chat_runner/chat_runner.ex index 9dd60d3..6c64f0b 100644 --- a/lib/elixir_ai/chat_runner/chat_runner.ex +++ b/lib/elixir_ai/chat_runner/chat_runner.ex @@ -1,7 +1,7 @@ defmodule ElixirAi.ChatRunner do require Logger use GenServer - alias ElixirAi.{AiTools, Conversation, Message} + alias ElixirAi.{AiTools, Conversation, Message, SystemPrompts} import ElixirAi.PubsubTopics import ElixirAi.ChatRunner.OutboundHelpers @@ -94,6 +94,12 @@ defmodule ElixirAi.ChatRunner do _ -> "auto" end + system_prompt = + case Conversation.find_category(name) do + {:ok, category} -> SystemPrompts.for_category(category) + _ -> nil + end + server_tools = AiTools.build_server_tools(self(), allowed_tools) liveview_tools = AiTools.build_liveview_tools(self(), allowed_tools) @@ -106,7 +112,7 @@ defmodule ElixirAi.ChatRunner do ElixirAi.ChatUtils.request_ai_response( self(), - messages, + messages_with_system_prompt(messages, system_prompt), server_tools ++ liveview_tools, provider, tool_choice @@ -117,6 +123,7 @@ defmodule ElixirAi.ChatRunner do %{ name: name, messages: messages, + system_prompt: system_prompt, streaming_response: nil, pending_tool_calls: [], allowed_tools: allowed_tools, diff --git a/lib/elixir_ai/chat_runner/conversation_calls.ex b/lib/elixir_ai/chat_runner/conversation_calls.ex index 78bb7c4..12d2969 100644 --- a/lib/elixir_ai/chat_runner/conversation_calls.ex +++ b/lib/elixir_ai/chat_runner/conversation_calls.ex @@ -10,7 +10,7 @@ defmodule ElixirAi.ChatRunner.ConversationCalls do ElixirAi.ChatUtils.request_ai_response( self(), - new_state.messages, + messages_with_system_prompt(new_state.messages, state.system_prompt), state.server_tools ++ state.liveview_tools, state.provider, effective_tool_choice diff --git a/lib/elixir_ai/chat_runner/outbound_helpers.ex b/lib/elixir_ai/chat_runner/outbound_helpers.ex index 1dc8f37..51de184 100644 --- a/lib/elixir_ai/chat_runner/outbound_helpers.ex +++ b/lib/elixir_ai/chat_runner/outbound_helpers.ex @@ -18,4 +18,7 @@ defmodule ElixirAi.ChatRunner.OutboundHelpers do message end + + def messages_with_system_prompt(messages, nil), do: messages + def messages_with_system_prompt(messages, prompt), do: [prompt | messages] end diff --git a/lib/elixir_ai/chat_runner/stream_handler.ex b/lib/elixir_ai/chat_runner/stream_handler.ex index c506475..c2c8acc 100644 --- a/lib/elixir_ai/chat_runner/stream_handler.ex +++ b/lib/elixir_ai/chat_runner/stream_handler.ex @@ -159,7 +159,7 @@ defmodule ElixirAi.ChatRunner.StreamHandler do ElixirAi.ChatUtils.request_ai_response( self(), - state.messages ++ [new_message], + messages_with_system_prompt(state.messages ++ [new_message], state.system_prompt), state.server_tools ++ state.liveview_tools, state.provider, state.tool_choice diff --git a/lib/elixir_ai/data/conversation.ex b/lib/elixir_ai/data/conversation.ex index ed74609..a268f63 100644 --- a/lib/elixir_ai/data/conversation.ex +++ b/lib/elixir_ai/data/conversation.ex @@ -101,6 +101,17 @@ defmodule ElixirAi.Conversation do end end + def find_category(name) do + sql = "SELECT category FROM conversations WHERE name = $(name) LIMIT 1" + params = %{"name" => name} + + case DbHelpers.run_sql(sql, params, "conversations") do + {:error, :db_error} -> {:error, :db_error} + [] -> {:error, :not_found} + [row | _] -> {:ok, row["category"] || "user-web"} + end + end + def find_allowed_tools(name) do sql = "SELECT allowed_tools FROM conversations WHERE name = $(name) LIMIT 1" params = %{"name" => name} diff --git a/lib/elixir_ai/system_prompts.ex b/lib/elixir_ai/system_prompts.ex new file mode 100644 index 0000000..2dc19c6 --- /dev/null +++ b/lib/elixir_ai/system_prompts.ex @@ -0,0 +1,14 @@ +defmodule ElixirAi.SystemPrompts do + @prompts %{ + "voice" => + "You are responding to voice-transcribed input. Keep replies concise and conversational. The user spoke aloud and their message was transcribed, so minor transcription errors may be present.", + "user-web" => nil + } + + def for_category(category) do + case Map.get(@prompts, category) do + nil -> nil + prompt -> %{role: :system, content: prompt} + end + end +end diff --git a/lib/elixir_ai_web/chat/chat_live.ex b/lib/elixir_ai_web/chat/chat_live.ex index 94ec723..43a7894 100644 --- a/lib/elixir_ai_web/chat/chat_live.ex +++ b/lib/elixir_ai_web/chat/chat_live.ex @@ -260,8 +260,17 @@ defmodule ElixirAiWeb.ChatLive do def handle_info({:ai_request_error, reason}, socket) do error_message = case reason do - %{__struct__: mod, reason: r} -> "#{inspect(mod)}: #{inspect(r)}" - _ -> inspect(reason) + "proxy error" <> _ -> + "Could not connect to AI provider. Please check your proxy and provider settings." + + %{__struct__: mod, reason: r} -> + "#{inspect(mod)}: #{inspect(r)}" + + msg when is_binary(msg) -> + msg + + _ -> + inspect(reason) end {:noreply, assign(socket, ai_error: error_message, streaming_response: nil)} diff --git a/lib/elixir_ai_web/chat/chat_message.ex b/lib/elixir_ai_web/chat/chat_message.ex index 366243b..7775e7a 100644 --- a/lib/elixir_ai_web/chat/chat_message.ex +++ b/lib/elixir_ai_web/chat/chat_message.ex @@ -1,8 +1,9 @@ defmodule ElixirAiWeb.ChatMessage do use Phoenix.Component alias Phoenix.LiveView.JS + import ElixirAiWeb.JsonDisplay - defp max_width_class, do: "max-w-300" + defp max_width_class, do: "max-w-full xl:max-w-300" attr :content, :string, required: true attr :tool_call_id, :string, required: true @@ -38,7 +39,7 @@ defmodule ElixirAiWeb.ChatMessage do def user_message(assigns) do ~H"""