fix: ai chat styling and some crashes

This commit is contained in:
2026-05-01 21:39:05 +02:00
parent b5ebea6ff2
commit f8b8ccabbd
11 changed files with 835 additions and 81 deletions

View File

@@ -527,13 +527,26 @@ defmodule BDS.AI.Chat do
Enum.join( Enum.join(
[ [
"Available blog data tools:", "Available blog data tools:",
"- Use blog_stats for aggregate counts of posts, media, tags, and categories.", "- Use get_blog_stats for aggregate counts of posts, media, tags, and categories.",
"- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.", "- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.",
"- Use read_post to read a post by ID, or read_post_by_slug to read a post by slug.",
"- Use read_post_by_slug to read full post content and metadata when a slug is known.", "- Use read_post_by_slug to read full post content and metadata when a slug is known.",
"- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.", "- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.",
"- Use list_media when asked for media titles, filenames, MIME types, or recent media lists. This is allowed project data access.", "- Use get_media for one media item by ID, list_media for media titles, filenames, MIME types, or recent media lists, and view_image for visual image inspection.",
"- Use update_post_metadata and update_media_metadata when asked to change titles, excerpts, tags, categories, alt text, or captions.",
"- Use get_post_backlinks, get_post_outlinks, get_post_media, and get_media_posts for relationship questions.",
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.", "- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data." "If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
"",
"Available UI Render Tools:",
"- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.",
"- Use render_table for tabular data, comparisons, and structured listings.",
"- Use render_form to collect structured user input.",
"- Use render_card for summaries, highlights, or actionable items.",
"- Use render_metric for a single KPI or important statistic.",
"- Use render_list for bullet lists, checklists, or simple enumerations.",
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.",
"When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data."
], ],
"\n" "\n"
) )

View File

@@ -4,15 +4,22 @@ defmodule BDS.AI.ChatTools do
import Ecto.Query import Ecto.Query
alias BDS.AI.Chat alias BDS.AI.Chat
alias BDS.Media, as: MediaContext
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.MCP.Queries alias BDS.MCP.Queries
alias BDS.Posts, as: PostsContext
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Projects.Project alias BDS.Projects.Project
alias BDS.Repo alias BDS.Repo
alias BDS.Search alias BDS.Search
@spec execute(String.t(), map(), String.t() | nil) :: map() @spec execute(String.t(), map(), String.t() | nil) :: map()
def execute("blog_stats", _arguments, project_id) do def execute("blog_stats", _arguments, project_id) do
execute("get_blog_stats", %{}, project_id)
end
def execute("get_blog_stats", _arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
%{ %{
@@ -73,6 +80,18 @@ defmodule BDS.AI.ChatTools do
end end
end end
def execute("read_post", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post -> %{post: Queries.post_detail(post)}
nil -> %{success: false, error: "not_found"}
end
end
def execute("list_posts", arguments, project_id) do def execute("list_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"]) limit = normalize_limit(arguments["limit"])
@@ -116,6 +135,70 @@ defmodule BDS.AI.ChatTools do
) )
end end
def execute("get_media", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Media,
id: arguments["mediaId"] || arguments["media_id"],
project_id: project_id
) do
%Media{} = media -> %{media: media_summary(media)}
nil -> %{success: false, error: "not_found"}
end
end
def execute("view_image", arguments, project_id) do
project_id = project_id || active_project_id()
media_id = arguments["mediaId"] || arguments["media_id"]
size = arguments["size"] || "medium"
case Repo.get_by(Media, id: media_id, project_id: project_id) do
%Media{mime_type: "image/" <> _rest} = media ->
case thumbnail_data_url(project_id, media, size) do
nil -> %{success: false, error: "thumbnail_not_available"}
data_url -> %{success: true, media: media_summary(media), data_url: data_url}
end
%Media{} = media ->
%{success: false, error: "not_image", mime_type: media.mime_type}
nil ->
%{success: false, error: "not_found"}
end
end
def execute("update_post_metadata", arguments, project_id) do
project_id = project_id || active_project_id()
post_id = arguments["postId"] || arguments["post_id"]
with %Post{} <- Repo.get_by(Post, id: post_id, project_id: project_id),
attrs <- metadata_attrs(arguments, ["title", "excerpt", "tags", "categories"]),
false <- attrs == %{},
{:ok, post} <- PostsContext.update_post(post_id, attrs) do
%{success: true, post: Queries.post_detail(post)}
else
nil -> %{success: false, error: "not_found"}
true -> %{success: false, error: "no_updates_provided"}
{:error, reason} -> %{success: false, error: inspect(reason)}
end
end
def execute("update_media_metadata", arguments, project_id) do
project_id = project_id || active_project_id()
media_id = arguments["mediaId"] || arguments["media_id"]
with %Media{} <- Repo.get_by(Media, id: media_id, project_id: project_id),
attrs <- metadata_attrs(arguments, ["title", "alt", "caption", "tags"]),
false <- attrs == %{},
{:ok, media} <- MediaContext.update_media(media_id, attrs) do
%{success: true, media: media_summary(media)}
else
nil -> %{success: false, error: "not_found"}
true -> %{success: false, error: "no_updates_provided"}
{:error, reason} -> %{success: false, error: inspect(reason)}
end
end
def execute("list_tags", _arguments, project_id) do def execute("list_tags", _arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
@@ -149,6 +232,56 @@ defmodule BDS.AI.ChatTools do
%{groups: groups, total_posts: result.total} %{groups: groups, total_posts: result.total}
end end
def execute("get_post_backlinks", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post ->
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
nil ->
%{success: false, error: "not_found"}
end
end
def execute("get_post_outlinks", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"],
project_id: project_id
) do
%Post{} = post ->
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
nil ->
%{success: false, error: "not_found"}
end
end
def execute("get_post_media", arguments, project_id) do
project_id = project_id || active_project_id()
post_id = arguments["postId"] || arguments["post_id"]
case Repo.get_by(Post, id: post_id, project_id: project_id) do
%Post{} = post -> %{success: true, post_id: post.id, media: post_media(project_id, post.id)}
nil -> %{success: false, error: "not_found"}
end
end
def execute("get_media_posts", arguments, project_id) do
project_id = project_id || active_project_id()
media_id = arguments["mediaId"] || arguments["media_id"]
case Repo.get_by(Media, id: media_id, project_id: project_id) do
%Media{} = media -> %{success: true, media_id: media.id, posts: media_posts(media.id)}
nil -> %{success: false, error: "not_found"}
end
end
def execute("render_table", arguments, _project_id) do def execute("render_table", arguments, _project_id) do
%{ %{
type: "table", type: "table",
@@ -162,7 +295,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
type: "chart", type: "chart",
title: arguments["title"], title: arguments["title"],
chart_type: arguments["chart_type"] || "bar", chart_type: arguments["chartType"] || arguments["chart_type"] || "bar",
series: arguments["series"] || [] series: arguments["series"] || []
} }
end end
@@ -237,6 +370,15 @@ defmodule BDS.AI.ChatTools do
"properties" => %{} "properties" => %{}
}) })
}, },
%{
name: "get_blog_stats",
spec:
tool_spec(
"get_blog_stats",
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
%{"type" => "object", "properties" => %{}}
)
},
%{ %{
name: "check_term", name: "check_term",
spec: spec:
@@ -259,6 +401,19 @@ defmodule BDS.AI.ChatTools do
post_search_schema(true) post_search_schema(true)
) )
}, },
%{
name: "read_post",
spec:
tool_spec(
"read_post",
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
)
},
%{ %{
name: "read_post_by_slug", name: "read_post_by_slug",
spec: spec:
@@ -281,6 +436,15 @@ defmodule BDS.AI.ChatTools do
post_search_schema(false) post_search_schema(false)
) )
}, },
%{
name: "get_media",
spec:
tool_spec(
"get_media",
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
media_id_schema()
)
},
%{ %{
name: "list_media", name: "list_media",
spec: spec:
@@ -290,6 +454,35 @@ defmodule BDS.AI.ChatTools do
limit_schema() limit_schema()
) )
}, },
%{
name: "view_image",
spec:
tool_spec(
"view_image",
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
media_id_schema(%{
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
})
)
},
%{
name: "update_post_metadata",
spec:
tool_spec(
"update_post_metadata",
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
update_post_metadata_schema()
)
},
%{
name: "update_media_metadata",
spec:
tool_spec(
"update_media_metadata",
"Update metadata for a media file: title, alt text, caption, or tags.",
update_media_metadata_schema()
)
},
%{ %{
name: "list_tags", name: "list_tags",
spec: spec:
@@ -319,6 +512,42 @@ defmodule BDS.AI.ChatTools do
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.", "Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
count_posts_schema() count_posts_schema()
) )
},
%{
name: "get_post_backlinks",
spec:
tool_spec(
"get_post_backlinks",
"Get all posts that link to a specific post.",
post_id_schema()
)
},
%{
name: "get_post_outlinks",
spec:
tool_spec(
"get_post_outlinks",
"Get all posts that a specific post links to.",
post_id_schema()
)
},
%{
name: "get_post_media",
spec:
tool_spec(
"get_post_media",
"Get media files linked to a specific post.",
post_id_schema()
)
},
%{
name: "get_media_posts",
spec:
tool_spec(
"get_media_posts",
"Get posts that use a specific media file.",
media_id_schema()
)
} }
] ]
else else
@@ -330,14 +559,18 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "render_card", name: "render_card",
spec: spec:
tool_spec("render_card", "Return a structured card payload", render_card_schema()) tool_spec(
"render_card",
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
render_card_schema()
)
}, },
%{ %{
name: "render_table", name: "render_table",
spec: spec:
tool_spec( tool_spec(
"render_table", "render_table",
"Return a structured table payload", "Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
render_table_schema() render_table_schema()
) )
}, },
@@ -346,40 +579,52 @@ defmodule BDS.AI.ChatTools do
spec: spec:
tool_spec( tool_spec(
"render_chart", "render_chart",
"Return a structured chart payload", "Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
render_chart_schema() render_chart_schema()
) )
}, },
%{ %{
name: "render_form", name: "render_form",
spec: spec:
tool_spec("render_form", "Return a structured form payload", render_form_schema()) tool_spec(
"render_form",
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
render_form_schema()
)
}, },
%{ %{
name: "render_metric", name: "render_metric",
spec: spec:
tool_spec( tool_spec(
"render_metric", "render_metric",
"Return a structured metric payload", "Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
render_metric_schema() render_metric_schema()
) )
}, },
%{ %{
name: "render_list", name: "render_list",
spec: spec:
tool_spec("render_list", "Return a structured list payload", render_list_schema()) tool_spec(
"render_list",
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
render_list_schema()
)
}, },
%{ %{
name: "render_tabs", name: "render_tabs",
spec: spec:
tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema()) tool_spec(
"render_tabs",
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
render_tabs_schema()
)
}, },
%{ %{
name: "render_mindmap", name: "render_mindmap",
spec: spec:
tool_spec( tool_spec(
"render_mindmap", "render_mindmap",
"Return a structured mindmap payload", "Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
render_mindmap_schema() render_mindmap_schema()
) )
} }
@@ -450,13 +695,65 @@ defmodule BDS.AI.ChatTools do
} }
end end
defp post_id_schema do
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
end
defp media_id_schema(extra_properties \\ %{}) do
%{
"type" => "object",
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
"required" => ["mediaId"]
}
end
defp update_post_metadata_schema do
%{
"type" => "object",
"properties" => %{
"postId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"excerpt" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["postId"]
}
end
defp update_media_metadata_schema do
%{
"type" => "object",
"properties" => %{
"mediaId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"alt" => %{"type" => "string"},
"caption" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["mediaId"]
}
end
defp render_table_schema do defp render_table_schema do
%{ %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
"title" => %{"type" => "string"}, "title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{"type" => "array"}, "columns" => %{
"rows" => %{"type" => "array"} "type" => "array",
"items" => %{"type" => "string"},
"description" => "Column header names"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows, each row is an array of cell values"
}
} }
} }
end end
@@ -465,10 +762,40 @@ defmodule BDS.AI.ChatTools do
%{ %{
"type" => "object", "type" => "object",
"properties" => %{ "properties" => %{
"title" => %{"type" => "string"}, "chartType" => %{
"chart_type" => %{"type" => "string"}, "type" => "string",
"series" => %{"type" => "array"} "enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
} "description" =>
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
},
"title" => %{"type" => "string", "description" => "Optional chart title"},
"series" => %{
"type" => "array",
"description" => "Array of data points.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Data point label"},
"value" => %{"type" => "number", "description" => "Data point value"},
"segments" => %{
"type" => "array",
"description" =>
"Segments within this data point. Required for stacked-bar and heatmap charts.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "number"}
},
"required" => ["label", "value"]
}
}
},
"required" => ["label"]
}
}
},
"required" => ["chartType", "series"]
} }
end end
@@ -570,6 +897,79 @@ defmodule BDS.AI.ChatTools do
|> Enum.sort_by(&String.downcase(to_string(&1.name))) |> Enum.sort_by(&String.downcase(to_string(&1.name)))
end end
defp metadata_attrs(arguments, keys) do
Enum.reduce(keys, %{}, fn key, acc ->
maybe_put(acc, String.to_atom(key), arguments[key])
end)
end
defp media_summary(%Media{} = media) do
%{
id: media.id,
filename: media.filename,
original_name: media.original_name,
mime_type: media.mime_type,
size: media.size,
width: media.width,
height: media.height,
title: media.title,
alt: media.alt,
caption: media.caption,
author: media.author,
language: media.language,
tags: media.tags || [],
created_at: media.created_at,
updated_at: media.updated_at
}
end
defp post_media(project_id, post_id) do
Repo.all(
from media in Media,
join: post_media in PostMedia,
on: post_media.media_id == media.id,
where: post_media.project_id == ^project_id and post_media.post_id == ^post_id,
order_by: [asc: post_media.sort_order, asc: media.updated_at]
)
|> Enum.map(&media_summary/1)
end
defp media_posts(media_id) do
MediaContext.list_linked_posts(media_id)
|> Enum.map(fn post ->
%{"id" => post.post_id, "title" => post.title, "sort_order" => post.sort_order}
end)
end
defp thumbnail_data_url(project_id, media, size) do
project = Repo.get!(Project, project_id)
size_key = thumbnail_size(size)
relative_path = MediaContext.thumbnail_paths(media)[size_key]
absolute_path = Path.join(project.data_path, relative_path || "")
with true <- is_binary(relative_path),
true <- File.exists?(absolute_path),
{:ok, binary} <- File.read(absolute_path) do
"data:#{thumbnail_mime(absolute_path)};base64," <> Base.encode64(binary)
else
_other -> nil
end
end
defp thumbnail_size("small"), do: :small
defp thumbnail_size("large"), do: :large
defp thumbnail_size(_size), do: :medium
defp thumbnail_mime(path) do
case Path.extname(path) |> String.downcase() do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
".png" -> "image/png"
".webp" -> "image/webp"
_other -> "application/octet-stream"
end
end
defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == "" defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == ""
defp normalize_term(value), do: value |> to_string() |> String.downcase() defp normalize_term(value), do: value |> to_string() |> String.downcase()

View File

@@ -71,6 +71,7 @@ defmodule BDS.Desktop.MainWindow do
frame -> frame ->
apply_restored_bounds(frame) apply_restored_bounds(frame)
BDS.Desktop.Shutdown.install_handlers(frame)
schedule_persist() schedule_persist()
{:noreply, {:noreply,

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Menu do
@moduledoc false @moduledoc false
use BDS.Desktop.MenuCompat use BDS.Desktop.MenuCompat
alias BDS.Desktop.Shutdown
alias Desktop.Window alias Desktop.Window
@impl true @impl true
@@ -27,7 +28,7 @@ defmodule BDS.Desktop.Menu do
end end
def handle_event("quit", menu) do def handle_event("quit", menu) do
Window.quit() Shutdown.request_quit()
{:noreply, menu} {:noreply, menu}
end end

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.MenuBar do
@moduledoc false @moduledoc false
use BDS.Desktop.MenuCompat use BDS.Desktop.MenuCompat
alias BDS.Desktop.Shutdown
alias BDS.UI.Commands alias BDS.UI.Commands
alias BDS.UI.MenuBar, as: ShellMenuBar alias BDS.UI.MenuBar, as: ShellMenuBar
alias Desktop.OS alias Desktop.OS
@@ -50,7 +51,7 @@ defmodule BDS.Desktop.MenuBar do
@impl true @impl true
def handle_event("quit", menu) do def handle_event("quit", menu) do
Window.quit() Shutdown.request_quit()
{:noreply, menu} {:noreply, menu}
end end

View File

@@ -0,0 +1,92 @@
defmodule BDS.Desktop.Shutdown do
@moduledoc false
alias BDS.Desktop.MainWindow
alias Desktop.Window
@stop_delay_ms 100
@spec install_handlers(term()) :: :ok
def install_handlers(frame) do
:wx.set_env(Desktop.Env.wx_env())
_ = :wxFrame.disconnect(frame, :close_window)
:wxFrame.connect(frame, :close_window,
callback: &__MODULE__.close_window/2,
userData: self()
)
_ = :wxFrame.disconnect(frame, :command_menu_selected, id: Desktop.Wx.wxID_EXIT())
:wxFrame.connect(frame, :command_menu_selected,
id: Desktop.Wx.wxID_EXIT(),
callback: &__MODULE__.command_menu_selected/2
)
:ok
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
@spec request_quit() :: :ok
def request_quit do
case Application.get_env(:bds, :desktop_shutdown_module, __MODULE__) do
__MODULE__ ->
start_shutdown_task()
module when is_atom(module) ->
module.request_quit()
end
end
@spec close_window(tuple(), term()) :: :ok
def close_window(_event, close_event) do
if :wxCloseEvent.canVeto(close_event) do
:wxCloseEvent.veto(close_event)
end
request_quit()
end
@spec command_menu_selected(tuple(), term()) :: :ok
def command_menu_selected(_event, _command_event) do
request_quit()
end
defp start_shutdown_task do
Task.start(fn ->
close_main_window()
Process.sleep(@stop_delay_ms)
System.stop(0)
end)
:ok
end
defp close_main_window do
with frame when not is_nil(frame) <- main_frame() do
:wx.set_env(Desktop.Env.wx_env())
if :wxWindow.isShown(frame) do
:wxWindow.hide(frame)
end
:wxWindow.destroy(frame)
else
_other -> :ok
end
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
defp main_frame do
Window.frame(MainWindow.window_id())
catch
:exit, _reason -> nil
end
end

View File

@@ -5796,11 +5796,15 @@ button svg * {
} }
.chat-input-container { .chat-input-container {
padding: 16px; padding: 8px 16px;
border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c)); border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e)); background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e));
} }
.chat-panel .chat-input-container {
padding: 8px 16px;
}
.chat-abort-button { .chat-abort-button {
display: block; display: block;
width: 100%; width: 100%;
@@ -5818,36 +5822,46 @@ button svg * {
background-color: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12)); background-color: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12));
} }
.chat-input-wrapper { .chat-panel .chat-input-wrapper {
--chat-input-line-height: 20px;
--chat-input-min-height: 20px;
--chat-input-max-height: 160px;
display: flex; display: flex;
align-items: flex-end; align-items: center;
gap: 8px; gap: 6px;
padding: 8px; min-height: 30px;
padding: 4px 6px;
border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c)); border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c));
border-radius: 8px; border-radius: 8px;
background-color: var(--vscode-input-background, var(--panel-2, #252526)); background-color: var(--vscode-input-background, var(--panel-2, #252526));
} }
.chat-input-wrapper:focus-within { .chat-panel .chat-input-wrapper:focus-within {
border-color: var(--vscode-focusBorder, var(--accent-color)); border-color: var(--vscode-focusBorder, var(--accent-color));
} }
.chat-input { .chat-panel .chat-input {
flex: 1; flex: 1;
min-height: 24px; display: block;
max-height: 200px; box-sizing: border-box;
height: var(--chat-input-min-height);
min-height: var(--chat-input-min-height);
max-height: var(--chat-input-max-height);
margin: 0;
padding: 0; padding: 0;
border: none; border: none;
outline: none; outline: none;
appearance: none;
background: transparent; background: transparent;
color: var(--vscode-input-foreground, inherit); color: var(--vscode-input-foreground, inherit);
font: inherit; font: inherit;
line-height: 1.5; line-height: var(--chat-input-line-height);
resize: none; resize: none;
overflow-y: auto; overflow-y: hidden;
vertical-align: top;
} }
.chat-input::placeholder { .chat-panel .chat-input::placeholder {
color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45)); color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45));
} }
@@ -5861,10 +5875,16 @@ button svg * {
font-size: 12px; font-size: 12px;
} }
.chat-send-button { .chat-panel .chat-send-button {
flex-shrink: 0; flex-shrink: 0;
width: 32px; box-sizing: border-box;
height: 32px; width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
max-width: 22px;
max-height: 22px;
padding: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -5872,16 +5892,17 @@ button svg * {
border-radius: 50%; border-radius: 50%;
background-color: var(--vscode-button-background, var(--accent-color)); background-color: var(--vscode-button-background, var(--accent-color));
color: var(--vscode-button-foreground, #ffffff); color: var(--vscode-button-foreground, #ffffff);
font-size: 18px; font-size: 15px;
line-height: 1;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s; transition: background-color 0.15s;
} }
.chat-send-button:hover:not(:disabled) { .chat-panel .chat-send-button:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground, var(--accent-color)); background-color: var(--vscode-button-hoverBackground, var(--accent-color));
} }
.chat-send-button:disabled, .chat-panel .chat-send-button:disabled,
.api-key-submit:disabled { .api-key-submit:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -6059,6 +6080,10 @@ button svg * {
.chat-input-container { .chat-input-container {
padding: 12px; padding: 12px;
} }
.chat-panel .chat-input-container {
padding: 6px 8px;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {

View File

@@ -731,8 +731,25 @@ document.addEventListener("DOMContentLoaded", () => {
return; return;
} }
textarea.style.height = "auto"; const styles = getComputedStyle(textarea);
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20;
const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160;
textarea.rows = 1;
textarea.style.minHeight = `${minHeight}px`;
if (textarea.value.trim() === "") {
textarea.style.height = `${minHeight}px`;
textarea.style.maxHeight = `${minHeight}px`;
textarea.style.overflowY = "hidden";
return;
}
textarea.style.maxHeight = `${maxHeight}px`;
textarea.style.height = "0px";
const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden";
}; };
this.syncScrollContainer = () => { this.syncScrollContainer = () => {

View File

@@ -482,7 +482,7 @@ defmodule BDS.AITest do
test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do
{:ok, project} = create_project_fixture("AI Chat") {:ok, project} = create_project_fixture("AI Chat")
:ok = seed_project_content(project.id) _fixtures = seed_project_content(project.id)
assert {:ok, _endpoint} = assert {:ok, _endpoint} =
BDS.AI.put_endpoint( BDS.AI.put_endpoint(
@@ -530,8 +530,15 @@ defmodule BDS.AITest do
message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and
String.contains?(message["content"], "Media: 1") and String.contains?(message["content"], "Media: 1") and
String.contains?(message["content"], "Available blog data tools") and String.contains?(message["content"], "Available blog data tools") and
String.contains?(message["content"], "get_blog_stats") and
String.contains?(message["content"], "list_posts") and String.contains?(message["content"], "list_posts") and
String.contains?(message["content"], "list_media") String.contains?(message["content"], "get_media") and
String.contains?(message["content"], "view_image") and
String.contains?(message["content"], "update_post_metadata") and
String.contains?(message["content"], "Available UI Render Tools") and
String.contains?(message["content"], "render_chart") and
String.contains?(message["content"], "heatmap") and
String.contains?(message["content"], "render_tabs")
end) end)
tool_descriptions = tool_descriptions =
@@ -540,24 +547,66 @@ defmodule BDS.AITest do
{get_in(tool, ["function", "name"]), get_in(tool, ["function", "description"])} {get_in(tool, ["function", "name"]), get_in(tool, ["function", "description"])}
end) end)
assert tool_descriptions["blog_stats"] =~ "aggregate" expected_old_app_tools = [
"get_blog_stats",
"search_posts",
"read_post",
"read_post_by_slug",
"list_posts",
"get_media",
"list_media",
"view_image",
"update_post_metadata",
"update_media_metadata",
"list_tags",
"list_categories",
"get_post_backlinks",
"get_post_outlinks",
"get_post_media",
"get_media_posts",
"render_chart",
"render_table",
"render_form",
"render_card",
"render_metric",
"render_list",
"render_tabs",
"render_mindmap"
]
assert Enum.all?(expected_old_app_tools, &Map.has_key?(tool_descriptions, &1))
assert tool_descriptions["get_blog_stats"] =~ "comprehensive blog statistics"
assert tool_descriptions["list_posts"] =~ "titles" assert tool_descriptions["list_posts"] =~ "titles"
assert tool_descriptions["list_posts"] =~ "URLs" assert tool_descriptions["list_posts"] =~ "URLs"
assert tool_descriptions["list_media"] =~ "filenames" assert tool_descriptions["list_media"] =~ "filenames"
assert tool_descriptions["render_chart"] =~ "interactive chart"
assert tool_descriptions["render_chart"] =~ "heatmap"
assert tool_descriptions["render_table"] =~ "tabular data"
assert tool_descriptions["render_tabs"] =~ "multiple tabs"
render_chart_schema =
first_request.tools
|> Enum.find(&(get_in(&1, ["function", "name"]) == "render_chart"))
|> get_in(["function", "parameters", "properties"])
assert get_in(render_chart_schema, ["chartType", "enum"]) == [
"bar",
"stacked-bar",
"line",
"area",
"pie",
"donut",
"heatmap"
]
assert get_in(render_chart_schema, ["series", "items", "properties", "segments"]) != nil
assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end) assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end)
end end
test "non-stat chat tools expose concrete project data" do test "non-stat chat tools expose concrete project data" do
{:ok, project} = create_project_fixture("Concrete Tools") {:ok, project} = create_project_fixture("Concrete Tools")
:ok = seed_project_content(project.id) %{post: post, media: media} = seed_project_content(project.id)
[post] =
Repo.all(
from post in Post,
where: post.project_id == ^project.id,
select: post
)
assert %{posts: [listed_post], total: 1} = assert %{posts: [listed_post], total: 1} =
BDS.AI.ChatTools.execute("list_posts", %{"limit" => 5}, project.id) BDS.AI.ChatTools.execute("list_posts", %{"limit" => 5}, project.id)
@@ -567,10 +616,68 @@ defmodule BDS.AITest do
assert listed_post["url"] == "/posts/#{post.slug}" assert listed_post["url"] == "/posts/#{post.slug}"
assert listed_post["updated_at"] == post.updated_at assert listed_post["updated_at"] == post.updated_at
assert %{post: read_post} =
BDS.AI.ChatTools.execute("read_post", %{"postId" => post.id}, project.id)
assert read_post["title"] == post.title
assert read_post["content"] == post.content
assert [listed_media] = BDS.AI.ChatTools.execute("list_media", %{"limit" => 5}, project.id) assert [listed_media] = BDS.AI.ChatTools.execute("list_media", %{"limit" => 5}, project.id)
assert listed_media.filename == "image.png" assert listed_media.filename == "image.png"
assert listed_media.mime_type == "image/png" assert listed_media.mime_type == "image/png"
assert listed_media.updated_at assert listed_media.updated_at
assert %{media: loaded_media} =
BDS.AI.ChatTools.execute("get_media", %{"mediaId" => media.id}, project.id)
assert loaded_media.id == media.id
assert loaded_media.title == "Hero"
assert %{linked_by: []} =
BDS.AI.ChatTools.execute("get_post_backlinks", %{"postId" => post.id}, project.id)
assert %{links_to: []} =
BDS.AI.ChatTools.execute("get_post_outlinks", %{"postId" => post.id}, project.id)
assert %{media: []} =
BDS.AI.ChatTools.execute("get_post_media", %{"postId" => post.id}, project.id)
assert %{posts: []} =
BDS.AI.ChatTools.execute("get_media_posts", %{"mediaId" => media.id}, project.id)
assert %{success: true, post: updated_post} =
BDS.AI.ChatTools.execute(
"update_post_metadata",
%{"postId" => post.id, "title" => "Updated AI Post"},
project.id
)
assert updated_post["title"] == "Updated AI Post"
assert %{success: true, media: updated_media} =
BDS.AI.ChatTools.execute(
"update_media_metadata",
%{"mediaId" => media.id, "alt" => "Updated alt"},
project.id
)
assert updated_media.alt == "Updated alt"
assert %{
type: "chart",
chart_type: "heatmap",
series: [%{"label" => "2026", "segments" => [%{"label" => "Jan", "value" => 2}]}]
} =
BDS.AI.ChatTools.execute(
"render_chart",
%{
"chartType" => "heatmap",
"series" => [
%{"label" => "2026", "segments" => [%{"label" => "Jan", "value" => 2}]}
]
},
project.id
)
end end
test "cancel_chat aborts an in-flight chat turn" do test "cancel_chat aborts an in-flight chat turn" do
@@ -621,36 +728,39 @@ defmodule BDS.AITest do
defp seed_project_content(project_id) do defp seed_project_content(project_id) do
now = Persistence.now_ms() now = Persistence.now_ms()
Repo.insert!( post =
Post.changeset(%Post{}, %{ Repo.insert!(
id: Ecto.UUID.generate(), Post.changeset(%Post{}, %{
project_id: project_id, id: Ecto.UUID.generate(),
title: "AI Post", project_id: project_id,
slug: "ai-post", title: "AI Post",
excerpt: "Summary", slug: "ai-post",
content: "Body", excerpt: "Summary",
status: :draft, content: "Body",
created_at: now, status: :draft,
updated_at: now, created_at: now,
do_not_translate: false updated_at: now,
}) do_not_translate: false
) })
)
Repo.insert!( media =
Media.changeset(%Media{}, %{ Repo.insert!(
id: Ecto.UUID.generate(), Media.changeset(%Media{}, %{
project_id: project_id, id: Ecto.UUID.generate(),
filename: "image.png", project_id: project_id,
original_name: "image.png", filename: "image.png",
mime_type: "image/png", original_name: "image.png",
size: 128, mime_type: "image/png",
file_path: "media/image.png", size: 128,
sidecar_path: "media/image.png.meta", title: "Hero",
created_at: now, file_path: "media/image.png",
updated_at: now sidecar_path: "media/image.png.meta",
}) created_at: now,
) updated_at: now
})
)
:ok %{post: post, media: media}
end end
end end

View File

@@ -2247,6 +2247,58 @@ defmodule BDS.Desktop.ShellLiveTest do
assert css =~ "line-height: 1.35;" assert css =~ "line-height: 1.35;"
end end
test "chat editor keeps empty input single-line until content grows" do
assert {:ok, conversation} = AI.start_chat(%{title: "Input Sizing", model: "gpt-4.1"})
{: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"
})
assert html =~ ~s(rows="1")
assert html =~ ~s(class="chat-input chat-surface-input")
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__))
assert css =~ "--chat-input-line-height: 20px;"
assert css =~ "--chat-input-min-height: 20px;"
assert css =~ ".chat-panel .chat-input-container"
assert css =~ "padding: 8px 16px;"
assert css =~ "padding: 6px 8px;"
assert css =~ ".chat-panel .chat-input-wrapper"
assert css =~ "min-height: 30px;"
assert css =~ "padding: 4px 6px;"
assert css =~ ".chat-panel .chat-input"
assert css =~ "box-sizing: border-box;"
assert css =~ "margin: 0;"
assert css =~ "height: var(--chat-input-min-height);"
assert css =~ "min-height: var(--chat-input-min-height);"
assert css =~ "overflow-y: hidden;"
assert css =~ ".chat-panel .chat-send-button"
assert css =~ "width: 22px;"
assert css =~ "height: 22px;"
assert css =~ "max-width: 22px;"
assert css =~ "max-height: 22px;"
assert css =~ "padding: 0;"
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__))
assert live_js =~
"minHeight = parseFloat(styles.getPropertyValue(\"--chat-input-min-height\"))"
assert live_js =~ "textarea.value.trim() === \"\""
assert live_js =~ "textarea.rows = 1;"
assert live_js =~ "textarea.style.minHeight = `${minHeight}px`;"
assert live_js =~ "textarea.style.height = `${minHeight}px`;"
assert live_js =~ "textarea.style.maxHeight = `${minHeight}px`;"
assert live_js =~ "textarea.style.height = \"0px\";"
assert live_js =~ "textarea.style.overflowY = nextHeight >= maxHeight ? \"auto\" : \"hidden\""
end
test "chat editor groups selector models by provider and uses catalog labels" do test "chat editor groups selector models by provider and uses catalog labels" do
updated_at = Persistence.now_ms() updated_at = Persistence.now_ms()

View File

@@ -3,6 +3,13 @@ defmodule BDS.DesktopTest do
import Plug.Test import Plug.Test
defmodule FakeShutdown do
def request_quit do
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :quit_requested)
:ok
end
end
test "desktop configuration no longer uses a pending adapter" do test "desktop configuration no longer uses a pending adapter" do
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
end end
@@ -99,6 +106,38 @@ defmodule BDS.DesktopTest do
assert menu_item(groups, :metadata_diff).shortcut == nil assert menu_item(groups, :metadata_diff).shortcut == nil
end end
test "native menu quit 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 {:noreply, %{}} = BDS.Desktop.MenuBar.handle_event("quit", %{})
assert_receive :quit_requested
end
test "icon menu quit 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 {:noreply, %{}} = BDS.Desktop.Menu.handle_event("quit", %{})
assert_receive :quit_requested
end
test "desktop root html is a LiveView shell and references only the live bootstrap assets" do test "desktop root html is a LiveView shell and references only the live bootstrap assets" do
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}") conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([])) conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
@@ -178,4 +217,7 @@ defmodule BDS.DesktopTest do
Image.new!(3, 2, color: [255, 0, 0]) Image.new!(3, 2, color: [255, 0, 0])
|> Image.write!(:memory, suffix: ".jpg", quality: 85) |> Image.write!(:memory, suffix: ".jpg", quality: 85)
end end
defp restore_env(key, nil), do: Application.delete_env(:bds, key)
defp restore_env(key, value), do: Application.put_env(:bds, key, value)
end end