diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index eb57c39..91050d8 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -318,14 +318,7 @@ defmodule BDS.AI do end def available_chat_models(current_model \\ nil) do - endpoint_models = - [:online, :airplane] - |> Enum.flat_map(fn kind -> - case get_endpoint(kind) do - {:ok, %{model: model}} when is_binary(model) and model != "" -> [model] - _other -> [] - end - end) + endpoint_models = configured_chat_models() preference_models = [:chat, :airplane_chat] @@ -336,10 +329,19 @@ defmodule BDS.AI do end end) - [current_model | endpoint_models ++ preference_models] + provider_names = catalog_provider_name_map() + endpoint_provider_map = Map.new(endpoint_models, &{&1.id, &1.provider}) + + [current_model | Enum.map(endpoint_models, & &1.id) ++ preference_models] |> Enum.filter(&(is_binary(&1) and String.trim(&1) != "")) |> Enum.uniq() - |> Enum.map(&%{id: &1, name: &1}) + |> Enum.map(&build_available_chat_model(&1, endpoint_provider_map, provider_names)) + |> Enum.sort_by(fn model -> + { + String.downcase(to_string(model.provider_name || model.provider || "")), + String.downcase(to_string(model.name || model.id)) + } + end) end def set_conversation_model(conversation_id, model_id) @@ -479,6 +481,81 @@ defmodule BDS.AI do } end + defp configured_chat_models do + [:online, :airplane] + |> Enum.flat_map(fn kind -> + case get_endpoint(kind) do + {:ok, %{model: model, url: url}} when is_binary(model) and model != "" -> + [%{id: model, provider: infer_endpoint_provider(kind, url)}] + + _other -> + [] + end + end) + end + + defp build_available_chat_model(model_id, endpoint_provider_map, provider_names) do + case get_catalog_model(model_id) do + {:ok, model} -> + provider = model.provider || Map.get(endpoint_provider_map, model_id, "other") + + %{ + id: model.model_id, + name: model.name || model.model_id, + provider: provider, + provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)), + context_window: model.context_window, + max_output_tokens: model.max_output_tokens + } + + {:error, :not_found} -> + provider = Map.get(endpoint_provider_map, model_id, "other") + + %{ + id: model_id, + name: model_id, + provider: provider, + provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)), + context_window: nil, + max_output_tokens: nil + } + end + end + + defp catalog_provider_name_map do + Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name}) + |> Map.new() + end + + defp infer_endpoint_provider(:online, _url), do: "generic-openai" + + defp infer_endpoint_provider(:airplane, url) when is_binary(url) do + normalized_url = String.downcase(url) + + cond do + String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") -> "ollama" + String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") -> "lmstudio" + true -> "generic-openai" + end + end + + defp infer_endpoint_provider(:airplane, _url), do: "generic-openai" + + defp fallback_provider_name("generic-openai"), do: "Generic OpenAI" + defp fallback_provider_name("lmstudio"), do: "LM Studio" + defp fallback_provider_name("mistral"), do: "Mistral" + defp fallback_provider_name("ollama"), do: "Ollama" + defp fallback_provider_name("openai"), do: "OpenAI" + + defp fallback_provider_name(provider) when is_binary(provider) and provider != "" do + provider + |> String.split(["-", "_"], trim: true) + |> Enum.map(&String.capitalize/1) + |> Enum.join(" ") + end + + defp fallback_provider_name(_provider), do: "Other" + defp run_one_shot(operation, payload, opts, formatter) do runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 49f38f9..c6874da 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -201,12 +201,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do %ChatConversation{} = conversation -> messages = AI.list_chat_messages(conversation.id) request = Map.get(assigns.chat_editor_requests, conversation.id) + available_models = AI.available_chat_models(conversation.model) %{ id: conversation.id, title: conversation.title || translated("chat.newChat"), model: conversation.model, - available_models: AI.available_chat_models(conversation.model), + available_models: available_models, + available_model_groups: group_available_models(available_models), model_selector_open?: Map.get(assigns.chat_model_selectors_open, conversation.id, false), input: Map.get(assigns.chat_editor_inputs, conversation.id, ""), messages: build_entries(messages, assigns), @@ -250,6 +252,24 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do def markdown_html(_content), do: "" + defp group_available_models(models) when is_list(models) do + models + |> Enum.group_by(&Map.get(&1, :provider, "other")) + |> Enum.map(fn {provider, entries} -> + %{ + provider: provider, + label: provider_group_label(entries, provider), + models: Enum.sort_by(entries, &String.downcase(to_string(Map.get(&1, :name) || Map.get(&1, :id)))) + } + end) + |> Enum.sort_by(&String.downcase(to_string(&1.label))) + end + + defp provider_group_label([%{provider_name: name} | _entries], _provider) when is_binary(name) and name != "", + do: name + + defp provider_group_label(_entries, provider) when is_binary(provider), do: provider + def payload_json(nil), do: "{}" def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) diff --git a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex index 0776e6a..860a1c8 100644 --- a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex +++ b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex @@ -22,18 +22,28 @@ <%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
- <%= for model <- @chat_editor.available_models do %> - + <%= for group <- @chat_editor.available_model_groups do %> +
+ <%= if length(@chat_editor.available_model_groups) > 1 do %> +
<%= group.label %>
+ <% end %> + + <%= for model <- group.models do %> + + <% end %> +
<% end %>
<% end %> diff --git a/priv/ui/app.css b/priv/ui/app.css index 9af33e2..b0954b1 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -4894,7 +4894,7 @@ button svg * { overflow-y: auto; display: flex; flex-direction: column; - gap: 4px; + gap: 8px; padding: 6px; border: 1px solid var(--vscode-dropdown-border, var(--line, #3c3c3c)); border-radius: 4px; @@ -4903,13 +4903,33 @@ button svg * { z-index: 100; } +.chat-model-provider-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.chat-model-provider-header { + padding: 2px 6px 0; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--vscode-descriptionForeground, rgba(255, 255, 255, 0.6)); +} + .chat-model-selector-option { + display: block; width: 100%; padding: 8px 12px; font-size: 12px; text-align: left; } +.chat-model-selector-option-name { + display: block; +} + .chat-model-selector-option.active { background-color: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18)); color: var(--vscode-list-activeSelectionForeground, inherit); diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 6a7dfbe..f9a22e1 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -1829,6 +1829,82 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ "Posts" end + test "chat editor groups selector models by provider and uses catalog labels" do + updated_at = Persistence.now_ms() + + Repo.insert!( + BDS.AI.CatalogProvider.changeset(%BDS.AI.CatalogProvider{}, %{ + id: "openai", + name: "OpenAI", + updated_at: updated_at + }) + ) + + Repo.insert!( + BDS.AI.CatalogProvider.changeset(%BDS.AI.CatalogProvider{}, %{ + id: "ollama", + name: "Ollama", + updated_at: updated_at + }) + ) + + Repo.insert!( + BDS.AI.Model.changeset(%BDS.AI.Model{}, %{ + provider: "openai", + model_id: "gpt-4o", + name: "GPT-4o", + context_window: 128_000, + max_input_tokens: 128_000, + max_output_tokens: 16_384, + updated_at: updated_at + }) + ) + + Repo.insert!( + BDS.AI.Model.changeset(%BDS.AI.Model{}, %{ + provider: "ollama", + model_id: "llama3.3", + name: "Llama 3.3", + context_window: 128_000, + max_input_tokens: 128_000, + max_output_tokens: 8_192, + updated_at: updated_at + }) + ) + + assert {:ok, _endpoint} = + AI.put_endpoint(:airplane, %{ + url: "http://localhost:11434/v1", + api_key: nil, + model: "llama3.3" + }) + + assert {:ok, conversation} = AI.start_chat(%{title: "Grouped Models", model: "gpt-4o"}) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + _html = + render_click(view, "pin_sidebar_item", %{ + "route" => "chat", + "id" => conversation.id, + "title" => conversation.title, + "subtitle" => conversation.model || "chat" + }) + + html = + view + |> element("[data-testid='chat-model-selector-button']") + |> render_click() + + assert html =~ ~s(data-testid="chat-model-provider-group") + assert html =~ ~s(data-provider="openai") + assert html =~ ~s(data-provider="ollama") + assert html =~ ">OpenAI<" + assert html =~ ">Ollama<" + assert html =~ ">GPT-4o<" + assert html =~ ">Llama 3.3<" + end + test "chat editor renders API-key-required state when online chat is not configured" do assert :ok = AI.set_airplane_mode(false)