Compare commits
3 Commits
11df11dbdb
...
c495a2ed0a
| Author | SHA1 | Date | |
|---|---|---|---|
| c495a2ed0a | |||
| 64a5eb525d | |||
| fef722c4c9 |
@@ -220,7 +220,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
def execute("count_posts", arguments, project_id) do
|
def execute("count_posts", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
|
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
|
||||||
{:ok, result} = Search.search_posts(project_id, "", search_filters(arguments))
|
result = search_all_counted_posts(project_id, arguments)
|
||||||
|
|
||||||
groups =
|
groups =
|
||||||
result.posts
|
result.posts
|
||||||
@@ -882,6 +882,16 @@ defmodule BDS.AI.ChatTools do
|
|||||||
|> Map.put(:limit, normalize_limit(arguments["limit"]))
|
|> Map.put(:limit, normalize_limit(arguments["limit"]))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp search_all_counted_posts(project_id, arguments) do
|
||||||
|
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
|
||||||
|
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
filters = Map.put(filters, :limit, max(total, 1))
|
||||||
|
{:ok, result} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
defp maybe_put(map, _key, nil), do: map
|
defp maybe_put(map, _key, nil), do: map
|
||||||
defp maybe_put(map, _key, ""), do: map
|
defp maybe_put(map, _key, ""), do: map
|
||||||
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
"messages" => request.messages,
|
"messages" => request.messages,
|
||||||
"max_tokens" => request.max_output_tokens
|
"max_tokens" => request.max_output_tokens
|
||||||
}
|
}
|
||||||
|> maybe_put_tools(request.tools)
|
|> maybe_put_tools(Map.get(request, :tools, []))
|
||||||
|
|
||||||
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
|
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
|
||||||
200 <- response.status do
|
200 <- response.status do
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
@spec chat_surface(term()) :: term()
|
@spec chat_surface(term()) :: term()
|
||||||
def chat_surface(assigns) do
|
def chat_surface(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={Map.get(@surface, :expanded?, false)} open={Map.get(@surface, :expanded?, false)}>
|
<details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={surface_expanded_attr(@surface)} open={Map.get(@surface, :expanded?, false)}>
|
||||||
<summary class="chat-inline-surface-header">
|
<summary class="chat-inline-surface-header">
|
||||||
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
|
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
|
||||||
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
|
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
|
||||||
@@ -566,6 +566,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
defp surface_icon("tabs"), do: "▧"
|
defp surface_icon("tabs"), do: "▧"
|
||||||
defp surface_icon(_type), do: "■"
|
defp surface_icon(_type), do: "■"
|
||||||
|
|
||||||
|
defp surface_expanded_attr(surface) do
|
||||||
|
if Map.get(surface, :expanded?, false), do: "true", else: "false"
|
||||||
|
end
|
||||||
|
|
||||||
defp surface_title(surface) do
|
defp surface_title(surface) do
|
||||||
cond do
|
cond do
|
||||||
present?(Map.get(surface, :title)) -> Map.get(surface, :title)
|
present?(Map.get(surface, :title)) -> Map.get(surface, :title)
|
||||||
|
|||||||
@@ -99,16 +99,15 @@
|
|||||||
|
|
||||||
<%= if message.role == :assistant do %>
|
<%= if message.role == :assistant do %>
|
||||||
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
|
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
|
||||||
|
<%= for surface <- message.inline_surfaces do %>
|
||||||
|
<.chat_surface surface={surface} />
|
||||||
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
|
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= for surface <- message.inline_surfaces do %>
|
|
||||||
<.chat_surface surface={surface} />
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
|
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
|
||||||
@@ -124,12 +123,11 @@
|
|||||||
<%= if @chat_editor.streaming_content != "" do %>
|
<%= if @chat_editor.streaming_content != "" do %>
|
||||||
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
|
||||||
|
<.chat_surface surface={surface} />
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
|
|
||||||
<.chat_surface surface={surface} />
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
||||||
|
|||||||
@@ -2,19 +2,29 @@ defmodule BDS.Desktop.Shutdown do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
alias BDS.Desktop.MainWindow
|
alias BDS.Desktop.MainWindow
|
||||||
|
alias Desktop.Wx
|
||||||
alias Desktop.Window
|
alias Desktop.Window
|
||||||
|
|
||||||
|
require Record
|
||||||
|
|
||||||
|
Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl"))
|
||||||
|
|
||||||
@spec install_handlers(term()) :: :ok
|
@spec install_handlers(term()) :: :ok
|
||||||
def install_handlers(frame) do
|
def install_handlers(frame) do
|
||||||
:wx.set_env(Desktop.Env.wx_env())
|
:wx.set_env(Desktop.Env.wx_env())
|
||||||
|
|
||||||
_ = :wxFrame.disconnect(frame, :close_window)
|
_ = :wxFrame.disconnect(frame, :close_window)
|
||||||
|
_ = :wxFrame.disconnect(frame, :command_menu_selected)
|
||||||
|
|
||||||
:wxFrame.connect(frame, :close_window,
|
:wxFrame.connect(frame, :close_window,
|
||||||
callback: &__MODULE__.close_window/2,
|
callback: &__MODULE__.close_window/2,
|
||||||
userData: self()
|
userData: self()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
:wxFrame.connect(frame, :command_menu_selected,
|
||||||
|
callback: &__MODULE__.command_menu_selected/2
|
||||||
|
)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
rescue
|
rescue
|
||||||
_error -> :ok
|
_error -> :ok
|
||||||
@@ -42,6 +52,17 @@ defmodule BDS.Desktop.Shutdown do
|
|||||||
request_quit()
|
request_quit()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec command_menu_selected(tuple(), term()) :: :ok
|
||||||
|
def command_menu_selected(wx(id: id), _command_event) do
|
||||||
|
if id == Wx.wxID_EXIT() do
|
||||||
|
request_quit()
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def command_menu_selected(_event, _command_event), do: :ok
|
||||||
|
|
||||||
defp start_shutdown_task do
|
defp start_shutdown_task do
|
||||||
Task.start(fn ->
|
Task.start(fn ->
|
||||||
MainWindow.persist_now()
|
MainWindow.persist_now()
|
||||||
|
|||||||
@@ -390,8 +390,7 @@ defmodule BDS.MCP.Tools do
|
|||||||
defp count_posts(params) do
|
defp count_posts(params) do
|
||||||
project = Queries.active_project!()
|
project = Queries.active_project!()
|
||||||
group_by = map_get(params, :groupBy, []) |> Enum.map(&to_string/1)
|
group_by = map_get(params, :groupBy, []) |> Enum.map(&to_string/1)
|
||||||
filters = Queries.search_filters(params)
|
result = search_all_counted_posts(project.id, params)
|
||||||
{:ok, result} = Search.search_posts(project.id, "", filters)
|
|
||||||
|
|
||||||
groups =
|
groups =
|
||||||
result.posts
|
result.posts
|
||||||
@@ -403,6 +402,16 @@ defmodule BDS.MCP.Tools do
|
|||||||
%{"groups" => groups, "total_posts" => result.total}
|
%{"groups" => groups, "total_posts" => result.total}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp search_all_counted_posts(project_id, params) do
|
||||||
|
filters = Queries.search_filters(params) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
|
||||||
|
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
filters = Map.put(filters, :limit, max(total, 1))
|
||||||
|
{:ok, result} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
defp read_post_by_slug(%{"slug" => slug} = params),
|
defp read_post_by_slug(%{"slug" => slug} = params),
|
||||||
do: read_post_by_slug(Map.put_new(params, :slug, slug))
|
do: read_post_by_slug(Map.put_new(params, :slug, slug))
|
||||||
|
|
||||||
|
|||||||
@@ -5620,6 +5620,8 @@ button svg * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-inline-surface {
|
.chat-inline-surface {
|
||||||
|
width: 100%;
|
||||||
|
margin: 12px 0 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.surfaceObserver = new MutationObserver(() => {
|
||||||
|
this.syncExpandedSurfaces();
|
||||||
|
});
|
||||||
|
|
||||||
this.handleScroll = () => {
|
this.handleScroll = () => {
|
||||||
if (!this.scrollContainer) {
|
if (!this.scrollContainer) {
|
||||||
this.stickToBottom = true;
|
this.stickToBottom = true;
|
||||||
@@ -832,6 +836,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
this.syncScrollContainer();
|
this.syncScrollContainer();
|
||||||
this.syncExpandedSurfaces();
|
this.syncExpandedSurfaces();
|
||||||
|
this.surfaceObserver.observe(this.el, { childList: true, subtree: true });
|
||||||
this.autoResize();
|
this.autoResize();
|
||||||
window.requestAnimationFrame(() => this.scrollToBottom(true));
|
window.requestAnimationFrame(() => this.scrollToBottom(true));
|
||||||
},
|
},
|
||||||
@@ -844,6 +849,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
destroyed() {
|
destroyed() {
|
||||||
|
this.surfaceObserver.disconnect();
|
||||||
this.el.removeEventListener("input", this.handleInput);
|
this.el.removeEventListener("input", this.handleInput);
|
||||||
this.el.removeEventListener("keydown", this.handleKeyDown);
|
this.el.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,27 @@ defmodule BDS.AITest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defmodule RecordingCompletionServer do
|
||||||
|
use Plug.Router
|
||||||
|
|
||||||
|
plug(:match)
|
||||||
|
plug(:dispatch)
|
||||||
|
|
||||||
|
post "/v1/chat/completions" do
|
||||||
|
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
||||||
|
send(Application.fetch_env!(:bds, :test_pid), {:completion_payload, Jason.decode!(body)})
|
||||||
|
|
||||||
|
response = %{
|
||||||
|
"choices" => [%{"message" => %{"content" => "Short Title"}}],
|
||||||
|
"usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> Plug.Conn.put_resp_content_type("application/json")
|
||||||
|
|> Plug.Conn.send_resp(200, Jason.encode!(response))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defmodule FakeRuntime do
|
defmodule FakeRuntime do
|
||||||
def generate(endpoint, request, opts) do
|
def generate(endpoint, request, opts) do
|
||||||
test_pid = Keyword.fetch!(opts, :test_pid)
|
test_pid = Keyword.fetch!(opts, :test_pid)
|
||||||
@@ -313,6 +334,33 @@ defmodule BDS.AITest do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "openai-compatible generation accepts title requests without tools" do
|
||||||
|
Application.put_env(:bds, :test_pid, self())
|
||||||
|
|
||||||
|
server =
|
||||||
|
start_supervised!({Bandit, plug: RecordingCompletionServer, port: 0, startup_log: false})
|
||||||
|
|
||||||
|
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
|
||||||
|
|
||||||
|
assert {:ok, %{content: "Short Title"}} =
|
||||||
|
BDS.AI.OpenAICompatibleRuntime.generate(
|
||||||
|
%{url: "http://127.0.0.1:#{port}/v1", api_key: nil},
|
||||||
|
%{
|
||||||
|
operation: :chat_title,
|
||||||
|
model: "qwen3.5-122b",
|
||||||
|
messages: [%{"role" => "user", "content" => "Topic: posts per month"}],
|
||||||
|
max_output_tokens: 20
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_received {:completion_payload, payload}
|
||||||
|
assert payload["model"] == "qwen3.5-122b"
|
||||||
|
assert payload["max_tokens"] == 20
|
||||||
|
refute Map.has_key?(payload, "tools")
|
||||||
|
refute Map.has_key?(payload, "tool_choice")
|
||||||
|
end
|
||||||
|
|
||||||
test "airplane mode routes title tasks to airplane endpoint and offline title model" do
|
test "airplane mode routes title tasks to airplane endpoint and offline title model" do
|
||||||
assert {:ok, _endpoint} =
|
assert {:ok, _endpoint} =
|
||||||
BDS.AI.put_endpoint(
|
BDS.AI.put_endpoint(
|
||||||
@@ -770,6 +818,44 @@ defmodule BDS.AITest do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "chat count_posts groups every matching post before returning groups" do
|
||||||
|
{:ok, project} = create_project_fixture("Count Posts")
|
||||||
|
|
||||||
|
month_counts = [{2, 4}, {3, 6}, {4, 3}]
|
||||||
|
|
||||||
|
for {month, count} <- month_counts,
|
||||||
|
index <- 1..count do
|
||||||
|
created_at = unix_ms!(NaiveDateTime.new!(Date.new!(2026, month, index), ~T[12:00:00]))
|
||||||
|
|
||||||
|
Repo.insert!(
|
||||||
|
Post.changeset(%Post{}, %{
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
project_id: project.id,
|
||||||
|
title: "AI Count #{month}-#{index}",
|
||||||
|
slug: "ai-count-#{month}-#{index}",
|
||||||
|
content: "Body",
|
||||||
|
status: :draft,
|
||||||
|
created_at: created_at,
|
||||||
|
updated_at: created_at,
|
||||||
|
do_not_translate: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert %{groups: groups, total_posts: 13} =
|
||||||
|
BDS.AI.ChatTools.execute(
|
||||||
|
"count_posts",
|
||||||
|
%{"groupBy" => ["month"], "year" => 2026},
|
||||||
|
project.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Enum.sort_by(groups, & &1["month"]) == [
|
||||||
|
%{"count" => 4, "month" => 2},
|
||||||
|
%{"count" => 6, "month" => 3},
|
||||||
|
%{"count" => 3, "month" => 4}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
test "cancel_chat aborts an in-flight chat turn" do
|
test "cancel_chat aborts an in-flight chat turn" do
|
||||||
assert {:ok, _endpoint} =
|
assert {:ok, _endpoint} =
|
||||||
BDS.AI.put_endpoint(
|
BDS.AI.put_endpoint(
|
||||||
@@ -853,4 +939,10 @@ defmodule BDS.AITest do
|
|||||||
|
|
||||||
%{post: post, media: media}
|
%{post: post, media: media}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp unix_ms!(%NaiveDateTime{} = naive_datetime) do
|
||||||
|
naive_datetime
|
||||||
|
|> DateTime.from_naive!("Etc/UTC")
|
||||||
|
|> DateTime.to_unix(:millisecond)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2271,6 +2271,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ "Blog Stats"
|
assert html =~ "Blog Stats"
|
||||||
assert html =~ "Metric"
|
assert html =~ "Metric"
|
||||||
assert html =~ "Posts"
|
assert html =~ "Posts"
|
||||||
|
assert html =~ ~r/chat-message-content.*data-testid="chat-inline-surface"/s
|
||||||
|
|
||||||
dismissed_html =
|
dismissed_html =
|
||||||
render_click(view, "dismiss_chat_surface", %{
|
render_click(view, "dismiss_chat_surface", %{
|
||||||
@@ -2333,21 +2334,113 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
})
|
})
|
||||||
|
|
||||||
assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2
|
assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2
|
||||||
assert length(:binary.matches(html, "data-expanded")) == 2
|
assert length(:binary.matches(html, ~s(data-expanded="true"))) == 2
|
||||||
|
assert length(:binary.matches(html, ~s(open=""))) == 2
|
||||||
assert html =~ "Earlier Missing Data"
|
assert html =~ "Earlier Missing Data"
|
||||||
assert html =~ "The first data request needs review."
|
assert html =~ "The first data request needs review."
|
||||||
assert html =~ "Latest Missing Data"
|
assert html =~ "Latest Missing Data"
|
||||||
assert html =~ "The second data request needs review."
|
assert html =~ "The second data request needs review."
|
||||||
|
assert html =~ ~r/chat-message-content.*Earlier Missing Data.*Latest Missing Data/s
|
||||||
|
end
|
||||||
|
|
||||||
|
test "chat editor keeps previous surfaces visible while a new update surface streams" do
|
||||||
|
assert :ok = AI.set_airplane_mode(false)
|
||||||
|
|
||||||
|
server =
|
||||||
|
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false})
|
||||||
|
|
||||||
|
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
|
||||||
|
|
||||||
|
assert {:ok, _endpoint} =
|
||||||
|
AI.put_endpoint(:online, %{
|
||||||
|
url: "http://127.0.0.1:#{port}/v1",
|
||||||
|
api_key: "online-secret",
|
||||||
|
model: "gpt-4.1"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, conversation} = AI.start_chat(%{title: "Update Surfaces", model: "gpt-4.1"})
|
||||||
|
|
||||||
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
Repo.insert!(
|
||||||
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
role: :assistant,
|
||||||
|
content: "Earlier missing data.",
|
||||||
|
tool_calls:
|
||||||
|
Jason.encode!([
|
||||||
|
%{
|
||||||
|
"id" => "call-card-old",
|
||||||
|
"name" => "render_card",
|
||||||
|
"arguments" => %{
|
||||||
|
"title" => "Earlier Missing Data",
|
||||||
|
"body" => "The first data request needs review."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
created_at: now
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
{: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 = render_change(view, "change_chat_editor_input", %{"message" => "Update missing data"})
|
||||||
|
|
||||||
|
_html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='chat-send-button']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
send(view.pid, {
|
||||||
|
:chat_tool_call,
|
||||||
|
conversation.id,
|
||||||
|
%{
|
||||||
|
id: "call-card-new",
|
||||||
|
name: "render_card",
|
||||||
|
arguments: %{
|
||||||
|
"title" => "Latest Missing Data",
|
||||||
|
"body" => "The second data request needs review."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
|
||||||
|
assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2
|
||||||
|
assert length(:binary.matches(html, ~s(data-expanded="true"))) == 2
|
||||||
|
assert length(:binary.matches(html, ~s(open=""))) == 2
|
||||||
|
assert html =~ "Earlier Missing Data"
|
||||||
|
assert html =~ "The first data request needs review."
|
||||||
|
assert html =~ "Latest Missing Data"
|
||||||
|
assert html =~ "The second data request needs review."
|
||||||
|
assert html =~ ~r/chat-message-content.*Earlier Missing Data.*Latest Missing Data/s
|
||||||
|
|
||||||
|
_html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='chat-abort-button']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
Process.sleep(350)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "chat editor hook reopens server-expanded A2UI surfaces after patches" do
|
test "chat editor hook reopens server-expanded A2UI surfaces after patches" do
|
||||||
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__))
|
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__))
|
||||||
chat_editor = File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__))
|
chat_editor = File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__))
|
||||||
|
|
||||||
assert chat_editor =~ "data-expanded={Map.get(@surface, :expanded?, false)}"
|
assert chat_editor =~ "data-expanded={surface_expanded_attr(@surface)}"
|
||||||
assert live_js =~ "this.syncExpandedSurfaces = () =>"
|
assert live_js =~ "this.syncExpandedSurfaces = () =>"
|
||||||
assert live_js =~ "querySelectorAll(\".chat-inline-surface[data-expanded='true']\")"
|
assert live_js =~ "querySelectorAll(\".chat-inline-surface[data-expanded='true']\")"
|
||||||
assert live_js =~ "surface.open = true;"
|
assert live_js =~ "surface.open = true;"
|
||||||
|
assert live_js =~ "this.surfaceObserver = new MutationObserver"
|
||||||
|
assert live_js =~ "this.surfaceObserver.disconnect();"
|
||||||
assert live_js =~ "this.syncExpandedSurfaces();"
|
assert live_js =~ "this.syncExpandedSurfaces();"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ defmodule BDS.DesktopTest do
|
|||||||
|
|
||||||
import Plug.Test
|
import Plug.Test
|
||||||
|
|
||||||
|
alias Desktop.Wx
|
||||||
|
|
||||||
|
require Record
|
||||||
|
|
||||||
|
Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl"))
|
||||||
|
|
||||||
defmodule FakeShutdown do
|
defmodule FakeShutdown do
|
||||||
def request_quit do
|
def request_quit do
|
||||||
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :quit_requested)
|
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :quit_requested)
|
||||||
@@ -145,8 +151,25 @@ defmodule BDS.DesktopTest do
|
|||||||
assert_receive :quit_requested
|
assert_receive :quit_requested
|
||||||
end
|
end
|
||||||
|
|
||||||
test "cmd-q remains handled by the desktop window quit handler" do
|
test "cmd-q is handled by the app-owned shutdown handler" do
|
||||||
refute function_exported?(BDS.Desktop.Shutdown, :command_menu_selected, 2)
|
assert {:module, BDS.Desktop.Shutdown} = Code.ensure_loaded(BDS.Desktop.Shutdown)
|
||||||
|
assert function_exported?(BDS.Desktop.Shutdown, :command_menu_selected, 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cmd-q callback requests app-owned shutdown" do
|
||||||
|
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
|
||||||
|
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)
|
||||||
|
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_module, FakeShutdown)
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_test_pid, self())
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
restore_env(:desktop_shutdown_module, previous_module)
|
||||||
|
restore_env(:desktop_shutdown_test_pid, previous_pid)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert :ok = BDS.Desktop.Shutdown.command_menu_selected(wx(id: Wx.wxID_EXIT()), nil)
|
||||||
|
assert_receive :quit_requested
|
||||||
end
|
end
|
||||||
|
|
||||||
test "app-owned shutdown delegates final termination to the desktop hard quit path" do
|
test "app-owned shutdown delegates final termination to the desktop hard quit path" do
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ defmodule BDS.MCPTest do
|
|||||||
|
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.MCP.ProposalStore
|
alias BDS.MCP.ProposalStore
|
||||||
|
alias BDS.Posts.Post
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Scripts.Script
|
alias BDS.Scripts.Script
|
||||||
alias BDS.Templates.Template
|
alias BDS.Templates.Template
|
||||||
@@ -88,6 +89,39 @@ defmodule BDS.MCPTest do
|
|||||||
assert read_result["post"]["slug"] == "travel-notes"
|
assert read_result["post"]["slug"] == "travel-notes"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "count_posts groups every matching post before returning groups", %{project: project} do
|
||||||
|
month_counts = [{2, 24}, {3, 26}, {4, 23}]
|
||||||
|
|
||||||
|
for {month, count} <- month_counts,
|
||||||
|
index <- 1..count do
|
||||||
|
day = rem(index - 1, 28) + 1
|
||||||
|
created_at = unix_ms!(NaiveDateTime.new!(Date.new!(2026, month, day), ~T[12:00:00]))
|
||||||
|
|
||||||
|
Repo.insert!(
|
||||||
|
Post.changeset(%Post{}, %{
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
project_id: project.id,
|
||||||
|
title: "MCP Count #{month}-#{index}",
|
||||||
|
slug: "mcp-count-#{month}-#{index}",
|
||||||
|
content: "Body",
|
||||||
|
status: :draft,
|
||||||
|
created_at: created_at,
|
||||||
|
updated_at: created_at,
|
||||||
|
do_not_translate: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert {:ok, %{"groups" => groups, "total_posts" => 73}} =
|
||||||
|
BDS.MCP.call_tool("count_posts", %{groupBy: ["month"], year: 2026})
|
||||||
|
|
||||||
|
assert Enum.sort_by(groups, & &1["month"]) == [
|
||||||
|
%{"count" => 24, "month" => 2},
|
||||||
|
%{"count" => 26, "month" => 3},
|
||||||
|
%{"count" => 23, "month" => 4}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
test "translation tools expose post and media translations and upsert media metadata", %{
|
test "translation tools expose post and media translations and upsert media metadata", %{
|
||||||
project: project,
|
project: project,
|
||||||
temp_dir: temp_dir
|
temp_dir: temp_dir
|
||||||
@@ -437,4 +471,10 @@ defmodule BDS.MCPTest do
|
|||||||
assert "bds://posts{?cursor}" in template_uris
|
assert "bds://posts{?cursor}" in template_uris
|
||||||
assert "bds://media{?cursor}" in template_uris
|
assert "bds://media{?cursor}" in template_uris
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp unix_ms!(%NaiveDateTime{} = naive_datetime) do
|
||||||
|
naive_datetime
|
||||||
|
|> DateTime.from_naive!("Etc/UTC")
|
||||||
|
|> DateTime.to_unix(:millisecond)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user