feat: more on step 6
This commit is contained in:
@@ -318,14 +318,7 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def available_chat_models(current_model \\ nil) do
|
def available_chat_models(current_model \\ nil) do
|
||||||
endpoint_models =
|
endpoint_models = configured_chat_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)
|
|
||||||
|
|
||||||
preference_models =
|
preference_models =
|
||||||
[:chat, :airplane_chat]
|
[:chat, :airplane_chat]
|
||||||
@@ -336,10 +329,19 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
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.filter(&(is_binary(&1) and String.trim(&1) != ""))
|
||||||
|> Enum.uniq()
|
|> 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
|
end
|
||||||
|
|
||||||
def set_conversation_model(conversation_id, model_id)
|
def set_conversation_model(conversation_id, model_id)
|
||||||
@@ -479,6 +481,81 @@ defmodule BDS.AI do
|
|||||||
}
|
}
|
||||||
end
|
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
|
defp run_one_shot(operation, payload, opts, formatter) do
|
||||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
|
|
||||||
|
|||||||
@@ -201,12 +201,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
%ChatConversation{} = conversation ->
|
%ChatConversation{} = conversation ->
|
||||||
messages = AI.list_chat_messages(conversation.id)
|
messages = AI.list_chat_messages(conversation.id)
|
||||||
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
||||||
|
available_models = AI.available_chat_models(conversation.model)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
id: conversation.id,
|
id: conversation.id,
|
||||||
title: conversation.title || translated("chat.newChat"),
|
title: conversation.title || translated("chat.newChat"),
|
||||||
model: conversation.model,
|
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),
|
model_selector_open?: Map.get(assigns.chat_model_selectors_open, conversation.id, false),
|
||||||
input: Map.get(assigns.chat_editor_inputs, conversation.id, ""),
|
input: Map.get(assigns.chat_editor_inputs, conversation.id, ""),
|
||||||
messages: build_entries(messages, assigns),
|
messages: build_entries(messages, assigns),
|
||||||
@@ -250,6 +252,24 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
def markdown_html(_content), 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(nil), do: "{}"
|
||||||
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
|
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
|
||||||
|
|
||||||
|
|||||||
@@ -22,18 +22,28 @@
|
|||||||
|
|
||||||
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
||||||
<div class="chat-model-selector-menu">
|
<div class="chat-model-selector-menu">
|
||||||
<%= for model <- @chat_editor.available_models do %>
|
<%= for group <- @chat_editor.available_model_groups do %>
|
||||||
<button
|
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
|
||||||
class={[
|
<%= if length(@chat_editor.available_model_groups) > 1 do %>
|
||||||
"chat-model-selector-option",
|
<div class="chat-model-provider-header"><%= group.label %></div>
|
||||||
if(model.id == @chat_editor.model, do: "active")
|
<% end %>
|
||||||
]}
|
|
||||||
type="button"
|
<%= for model <- group.models do %>
|
||||||
phx-click="select_chat_model"
|
<button
|
||||||
phx-value-model={model.id}
|
class={[
|
||||||
>
|
"chat-model-selector-option",
|
||||||
<%= model.name %>
|
if(model.id == @chat_editor.model, do: "active")
|
||||||
</button>
|
]}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_chat_model"
|
||||||
|
phx-value-model={model.id}
|
||||||
|
data-testid="chat-model-selector-option"
|
||||||
|
data-provider={group.provider}
|
||||||
|
>
|
||||||
|
<span class="chat-model-selector-option-name"><%= model.name || model.id %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -4894,7 +4894,7 @@ button svg * {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--vscode-dropdown-border, var(--line, #3c3c3c));
|
border: 1px solid var(--vscode-dropdown-border, var(--line, #3c3c3c));
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -4903,13 +4903,33 @@ button svg * {
|
|||||||
z-index: 100;
|
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 {
|
.chat-model-selector-option {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-model-selector-option-name {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-model-selector-option.active {
|
.chat-model-selector-option.active {
|
||||||
background-color: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18));
|
background-color: var(--vscode-list-activeSelectionBackground, rgba(0, 122, 204, 0.18));
|
||||||
color: var(--vscode-list-activeSelectionForeground, inherit);
|
color: var(--vscode-list-activeSelectionForeground, inherit);
|
||||||
|
|||||||
@@ -1829,6 +1829,82 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ "Posts"
|
assert html =~ "Posts"
|
||||||
end
|
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
|
test "chat editor renders API-key-required state when online chat is not configured" do
|
||||||
assert :ok = AI.set_airplane_mode(false)
|
assert :ok = AI.set_airplane_mode(false)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user