Compare commits

..

6 Commits

19 changed files with 2098 additions and 184 deletions

View File

@@ -452,7 +452,7 @@ defmodule BDS.AI.Chat do
end
defp build_chat_request(conversation, messages, model, project_id, tools) do
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id)}
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)}
%{
operation: :chat,
@@ -479,10 +479,44 @@ defmodule BDS.AI.Chat do
case Catalog.decode_nullable_json(message.tool_calls) do
nil -> base
tool_calls -> Map.put(base, "tool_calls", tool_calls)
tool_calls -> Map.put(base, "tool_calls", tool_calls_for_runtime(tool_calls))
end
end
defp tool_calls_for_runtime(tool_calls) when is_list(tool_calls) do
Enum.map(tool_calls, &tool_call_for_runtime/1)
end
defp tool_calls_for_runtime(tool_calls), do: tool_calls
defp tool_call_for_runtime(%{"type" => "function", "function" => %{} = _function} = tool_call) do
tool_call
end
defp tool_call_for_runtime(%{"id" => id, "name" => name} = tool_call) do
%{
"id" => id,
"type" => "function",
"function" => %{
"name" => name,
"arguments" => Jason.encode!(tool_call["arguments"] || %{})
}
}
end
defp tool_call_for_runtime(%{id: id, name: name} = tool_call) do
%{
"id" => id,
"type" => "function",
"function" => %{
"name" => name,
"arguments" => Jason.encode!(Map.get(tool_call, :arguments) || %{})
}
}
end
defp tool_call_for_runtime(tool_call), do: tool_call
defp truncate_chat_messages(messages, model, tools) do
context_window = model_context_window(model)
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))
@@ -511,15 +545,46 @@ defmodule BDS.AI.Chat do
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
end
defp chat_system_prompt(project_id) do
defp chat_system_prompt(project_id, tools) do
base = get_setting("ai.system_prompt") || @default_system_prompt
case project_stats_summary(project_id) do
nil -> base
summary -> base <> "\n\nCurrent blog statistics:\n" <> summary
with true <- tools != [],
summary when is_binary(summary) <- project_stats_summary(project_id) do
base <> "\n\nCurrent blog statistics:\n" <> summary <> "\n\n" <> blog_tool_guidance()
else
_other -> base
end
end
defp blog_tool_guidance do
Enum.join(
[
"Available blog data tools:",
"- 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 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 list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post 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.",
"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"
)
end
defp project_stats_summary(nil), do: nil
defp project_stats_summary(project_id) do

View File

@@ -4,13 +4,22 @@ defmodule BDS.AI.ChatTools do
import Ecto.Query
alias BDS.AI.Chat
alias BDS.Media, as: MediaContext
alias BDS.Media.Media
alias BDS.MCP.Queries
alias BDS.Posts, as: PostsContext
alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Projects.Project
alias BDS.Repo
alias BDS.Search
@spec execute(String.t(), map(), String.t() | nil) :: map()
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()
%{
@@ -23,20 +32,91 @@ defmodule BDS.AI.ChatTools do
}
end
def execute("list_posts", arguments, project_id) do
limit = normalize_limit(arguments["limit"])
def execute("check_term", arguments, project_id) do
project_id = project_id || active_project_id()
term = normalize_term(arguments["term"])
Repo.all(
from(post in Post,
where: post.project_id == ^project_id,
order_by: [desc: post.updated_at],
limit: ^limit,
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status}
)
)
posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
tag_post_count =
Enum.count(posts, fn post ->
Enum.any?(post.tags || [], &(normalize_term(&1) == term))
end)
category_post_count =
Enum.count(posts, fn post ->
Enum.any?(post.categories || [], &(normalize_term(&1) == term))
end)
%{
is_category: category_post_count > 0,
category_post_count: category_post_count,
is_tag: tag_post_count > 0,
tag_post_count: tag_post_count
}
end
def execute("search_posts", arguments, project_id) do
project_id = project_id || active_project_id()
filters = search_filters(arguments)
{:ok, result} = Search.search_posts(project_id, arguments["query"] || "", filters)
%{
posts: Enum.map(result.posts, &Queries.post_summary/1),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
def execute("read_post_by_slug", arguments, project_id) do
project_id = project_id || active_project_id()
case Repo.get_by(Post, project_id: project_id, slug: arguments["slug"]) do
%Post{} = post -> %{post: Queries.post_detail(post)}
nil -> %{error: "not_found"}
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
project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"])
offset = normalize_offset(arguments["offset"])
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
{:ok, result} = Search.search_posts(project_id, "", filters)
%{
posts:
Enum.map(result.posts, fn post ->
post
|> Queries.post_summary()
|> Map.put("url", "/posts/#{post.slug}")
|> Map.put("updated_at", post.updated_at)
end),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
def execute("list_media", arguments, project_id) do
project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"])
Repo.all(
@@ -48,12 +128,160 @@ defmodule BDS.AI.ChatTools do
id: media.id,
title: media.title,
mime_type: media.mime_type,
filename: media.filename
filename: media.filename,
updated_at: media.updated_at
}
)
)
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
project_id = project_id || active_project_id()
%{
tags: counted_terms(project_id, :tags),
count: length(counted_terms(project_id, :tags))
}
end
def execute("list_categories", _arguments, project_id) do
project_id = project_id || active_project_id()
%{
categories: counted_terms(project_id, :categories),
count: length(counted_terms(project_id, :categories))
}
end
def execute("count_posts", arguments, project_id) do
project_id = project_id || active_project_id()
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
{:ok, result} = Search.search_posts(project_id, "", search_filters(arguments))
groups =
result.posts
|> Enum.flat_map(&Queries.group_rows(&1, group_by))
|> Enum.group_by(& &1, fn _row -> 1 end)
|> Enum.map(fn {row, counts} -> Map.put(row, "count", length(counts)) end)
|> Enum.sort_by(&Map.to_list/1)
%{groups: groups, total_posts: result.total}
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
%{
type: "table",
@@ -67,7 +295,7 @@ defmodule BDS.AI.ChatTools do
%{
type: "chart",
title: arguments["title"],
chart_type: arguments["chart_type"] || "bar",
chart_type: arguments["chartType"] || arguments["chart_type"] || "bar",
series: arguments["series"] || []
}
end
@@ -142,19 +370,184 @@ defmodule BDS.AI.ChatTools do
"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",
spec:
tool_spec(
"check_term",
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
%{
"type" => "object",
"properties" => %{"term" => %{"type" => "string"}},
"required" => ["term"]
}
)
},
%{
name: "search_posts",
spec:
tool_spec(
"search_posts",
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
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",
spec:
tool_spec(
"read_post_by_slug",
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{
"type" => "object",
"properties" => %{"slug" => %{"type" => "string"}},
"required" => ["slug"]
}
)
},
%{
name: "list_posts",
spec:
tool_spec("list_posts", "List recent posts in the active project", limit_schema())
tool_spec(
"list_posts",
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
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",
spec:
tool_spec(
"list_media",
"List recent media items in the active project",
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
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",
spec:
tool_spec(
"list_tags",
"List all tags used across blog posts with post counts.",
%{
"type" => "object",
"properties" => %{}
}
)
},
%{
name: "list_categories",
spec:
tool_spec(
"list_categories",
"List all categories used across blog posts with post counts.",
%{"type" => "object", "properties" => %{}}
)
},
%{
name: "count_posts",
spec:
tool_spec(
"count_posts",
"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()
)
},
%{
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
@@ -166,14 +559,18 @@ defmodule BDS.AI.ChatTools do
%{
name: "render_card",
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",
spec:
tool_spec(
"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()
)
},
@@ -182,40 +579,52 @@ defmodule BDS.AI.ChatTools do
spec:
tool_spec(
"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()
)
},
%{
name: "render_form",
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",
spec:
tool_spec(
"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()
)
},
%{
name: "render_list",
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",
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",
spec:
tool_spec(
"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()
)
}
@@ -245,13 +654,106 @@ defmodule BDS.AI.ChatTools do
}
end
defp post_search_schema(require_query) do
schema = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"language" => %{"type" => "string"},
"missingTranslationLanguage" => %{"type" => "string"},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
"offset" => %{"type" => "integer", "minimum" => 0}
}
}
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
end
defp count_posts_schema do
%{
"type" => "object",
"properties" => %{
"groupBy" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => ["year", "month", "tag", "category", "status"]
}
},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["groupBy"]
}
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
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"columns" => %{"type" => "array"},
"rows" => %{"type" => "array"}
"title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{
"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
@@ -260,11 +762,41 @@ defmodule BDS.AI.ChatTools do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"chart_type" => %{"type" => "string"},
"series" => %{"type" => "array"}
"chartType" => %{
"type" => "string",
"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
defp render_form_schema do
@@ -334,6 +866,114 @@ defmodule BDS.AI.ChatTools do
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
defp normalize_limit(_value), do: 10
defp normalize_offset(value) when is_integer(value) and value >= 0, do: value
defp normalize_offset(_value), do: 0
defp search_filters(arguments) do
%{}
|> maybe_put(:category, arguments["category"])
|> maybe_put(:tags, arguments["tags"])
|> maybe_put(:language, arguments["language"])
|> maybe_put(:missing_translation_language, arguments["missingTranslationLanguage"])
|> maybe_put(:year, arguments["year"])
|> maybe_put(:month, arguments["month"])
|> maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp counted_terms(project_id, field) do
Repo.all(
from post in Post, where: post.project_id == ^project_id, select: field(post, ^field)
)
|> List.flatten()
|> Enum.reject(&blank?/1)
|> Enum.frequencies()
|> Enum.map(fn {term, count} -> %{name: term, count: count} end)
|> Enum.sort_by(&String.downcase(to_string(&1.name)))
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 normalize_term(value), do: value |> to_string() |> String.downcase()
defp active_project_id do
Repo.one(from(project in Project, where: project.is_active == true, select: project.id))
end

View File

@@ -6,16 +6,24 @@ defmodule BDS.Desktop.MainWindow do
alias Desktop.Window
@window_id __MODULE__
@server_name BDS.Desktop.MainWindow.Watcher
@persist_interval_ms 1_000
@default_size {1280, 780}
@default_min_size {800, 600}
@state_file "window-state.json"
def start_link(_opts) do
GenServer.start_link(__MODULE__, :ok)
GenServer.start_link(__MODULE__, :ok, name: @server_name)
end
def window_id, do: @window_id
def server_name, do: @server_name
def persist_now(timeout \\ 100) do
GenServer.call(@server_name, :persist_bounds_now, timeout)
catch
:exit, _reason -> :ok
end
def window_options(extra_opts \\ []) do
desktop_config = Application.get_env(:bds, :desktop, [])
@@ -71,6 +79,7 @@ defmodule BDS.Desktop.MainWindow do
frame ->
apply_restored_bounds(frame)
BDS.Desktop.Shutdown.install_handlers(frame)
schedule_persist()
{:noreply,
@@ -90,8 +99,13 @@ defmodule BDS.Desktop.MainWindow do
end
@impl true
def terminate(_reason, %{frame: frame, last_bounds: last_bounds}) do
if bounds = current_bounds(frame) || last_bounds do
def handle_call(:persist_bounds_now, _from, state) do
{:reply, :ok, persist_current_bounds(state)}
end
@impl true
def terminate(_reason, %{last_bounds: last_bounds}) do
if bounds = last_bounds do
_ = persist_bounds(bounds)
end
@@ -102,6 +116,16 @@ defmodule BDS.Desktop.MainWindow do
Process.send_after(self(), :persist_bounds, @persist_interval_ms)
end
defp persist_current_bounds(%{frame: frame} = state) do
next_bounds = current_bounds(frame) || state.last_bounds
if next_bounds do
_ = persist_bounds(next_bounds)
end
%{state | last_bounds: next_bounds}
end
defp apply_restored_bounds(frame) do
case restore_bounds() do
%{x: x, y: y, width: width, height: height} ->
@@ -126,6 +150,7 @@ defmodule BDS.Desktop.MainWindow do
defp current_bounds(nil), do: nil
defp current_bounds(frame) do
try do
with_wx_env(fn ->
cond do
not :wxWindow.isShown(frame) ->
@@ -143,6 +168,12 @@ defmodule BDS.Desktop.MainWindow do
%{x: x, y: y, width: width, height: height}
end
end)
rescue
ErlangError -> nil
FunctionClauseError -> nil
catch
:exit, _reason -> nil
end
end
defp with_wx_env(fun) do

View File

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

View File

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

View File

@@ -62,6 +62,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
next_turn_index = turn_index + 1
{entries, start_entry(message, next_turn_index, assigns), next_turn_index}
:assistant ->
next_entry = start_entry(message, turn_index, assigns)
if tool_only_assistant_entry?(current_entry) do
{entries, merge_tool_only_entry(current_entry, next_entry), turn_index}
else
entries = finalize_entry(entries, current_entry)
{entries, next_entry, turn_index}
end
_other ->
entries = finalize_entry(entries, current_entry)
{entries, start_entry(message, turn_index, assigns), turn_index}
@@ -99,6 +109,22 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
end
end
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
String.trim(content || "") == "" and
(entry.tool_markers != [] or entry.inline_surfaces != [] or entry.tool_surfaces != [])
end
defp tool_only_assistant_entry?(_entry), do: false
defp merge_tool_only_entry(tool_entry, assistant_entry) do
%{
assistant_entry
| tool_markers: tool_entry.tool_markers ++ assistant_entry.tool_markers,
inline_surfaces: tool_entry.inline_surfaces ++ assistant_entry.inline_surfaces,
tool_surfaces: tool_entry.tool_surfaces ++ assistant_entry.tool_surfaces
}
end
defp pending_user_message(_messages, nil), do: nil
defp pending_user_message(messages, %{message: message}) when is_binary(message) do

View File

@@ -1,17 +1,18 @@
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
<div class="chat-panel-header">
<div class="chat-panel-title">
<span class="chat-panel-title-main">
<%= if @chat_editor.needs_api_key? do %>
<%= translated("chat.setupTitle") %>
<% else %>
<%= @chat_editor.title %>
<% end %>
</div>
</span>
<%= unless @chat_editor.needs_api_key? do %>
<div class="chat-panel-header-actions">
<span class="chat-model-selector-wrap">
<button
class="chat-model-selector-button"
class="chat-model-selector-button chat-model-selector-inline"
type="button"
phx-click="toggle_chat_model_selector"
data-testid="chat-model-selector-button"
@@ -47,9 +48,10 @@
<% end %>
</div>
<% end %>
</div>
</span>
<% end %>
</div>
</div>
<div class="chat-messages chat-surface-scroll">
<%= if @chat_editor.needs_api_key? do %>
@@ -83,7 +85,7 @@
<div class="chat-message-header">
<span class="chat-message-role"><%= message_role_label(:user) %></span>
</div>
<div class="chat-message-text"><%= @chat_editor.pending_user_message %></div>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
</div>
</div>
<% end %>
@@ -95,15 +97,13 @@
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
<.chat_tool_markers markers={message.tool_markers} />
<div class="chat-message-text">
<%= if message.role == :assistant do %>
<%= markdown_html(message.content || "") %>
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
<% else %>
<%= message.content || "" %>
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
<% end %>
</div>
</div>
</div>
<%= for surface <- message.inline_surfaces do %>
<.chat_surface surface={surface} />

View File

@@ -17,6 +17,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
"online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""),
"online_chat_model" =>
get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, ""),
"online_chat_tools" =>
model_supports_tool_calls?(
get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, "")
),
"online_title_model" => get_model_preference(:title),
"online_image_analysis_model" => get_model_preference(:image_analysis),
"offline_url" => Map.get(airplane_endpoint || %{}, :url, ""),
@@ -25,6 +29,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
"offline_chat_model" =>
get_model_preference(:airplane_chat) ||
Map.get(airplane_endpoint || %{}, :model, ""),
"offline_chat_tools" =>
model_supports_tool_calls?(
get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, "")
),
"offline_title_model" => get_model_preference(:airplane_title),
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis),
"system_prompt" => EditorSettings.get_global_setting("ai.system_prompt") || ""
@@ -90,9 +98,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
:ok <- AI.delete_endpoint(:mistral),
:ok <- AI.set_airplane_mode(attrs.offline_mode),
:ok <- maybe_put_model_preference(:chat, attrs.online_chat_model),
:ok <-
maybe_put_chat_model_capabilities(attrs.online_chat_model, attrs.online_chat_tools),
:ok <- maybe_put_model_preference(:title, attrs.online_title_model),
:ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_model),
:ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model),
:ok <-
maybe_put_chat_model_capabilities(attrs.offline_chat_model, attrs.offline_chat_tools),
:ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model),
:ok <-
maybe_put_model_preference(
@@ -134,12 +146,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
online_url: blank_to_nil(Map.get(draft, "online_url")),
online_api_key: blank_to_nil(Map.get(draft, "online_api_key")),
online_chat_model: blank_to_nil(Map.get(draft, "online_chat_model")),
online_chat_tools: truthy?(Map.get(draft, "online_chat_tools")),
online_title_model: blank_to_nil(Map.get(draft, "online_title_model")),
online_image_analysis_model: blank_to_nil(Map.get(draft, "online_image_analysis_model")),
offline_url: blank_to_nil(Map.get(draft, "offline_url")),
offline_api_key: blank_to_nil(Map.get(draft, "offline_api_key")),
offline_mode: truthy?(Map.get(draft, "offline_mode")),
offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")),
offline_chat_tools: truthy?(Map.get(draft, "offline_chat_tools")),
offline_title_model: blank_to_nil(Map.get(draft, "offline_title_model")),
offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")),
system_prompt: Map.get(draft, "system_prompt", "")
@@ -151,12 +165,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
"online_url" => Map.get(params, "online_url", ""),
"online_api_key" => Map.get(params, "online_api_key", ""),
"online_chat_model" => Map.get(params, "online_chat_model", ""),
"online_chat_tools" => truthy?(Map.get(params, "online_chat_tools")),
"online_title_model" => Map.get(params, "online_title_model", ""),
"online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""),
"offline_url" => Map.get(params, "offline_url", ""),
"offline_api_key" => Map.get(params, "offline_api_key", ""),
"offline_mode" => truthy?(Map.get(params, "offline_mode")),
"offline_chat_model" => Map.get(params, "offline_chat_model", ""),
"offline_chat_tools" => truthy?(Map.get(params, "offline_chat_tools")),
"offline_title_model" => Map.get(params, "offline_title_model", ""),
"offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""),
"system_prompt" => Map.get(params, "system_prompt", "")
@@ -174,6 +190,24 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp maybe_put_model_preference(_key, ""), do: :ok
defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value)
defp maybe_put_chat_model_capabilities(nil, _supports_tool_calls), do: :ok
defp maybe_put_chat_model_capabilities("", _supports_tool_calls), do: :ok
defp maybe_put_chat_model_capabilities(model, supports_tool_calls) do
existing = BDS.AI.Catalog.model_capabilities(model)
AI.put_model_capabilities(model, %{
supports_attachment: existing.supports_attachment,
supports_tool_calls: supports_tool_calls
})
end
defp model_supports_tool_calls?(nil), do: false
defp model_supports_tool_calls?(""), do: false
defp model_supports_tool_calls?(model),
do: BDS.AI.Catalog.model_capabilities(model).supports_tool_calls
defp put_endpoint_preferences(kind, url, api_key, primary_model) do
if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do
AI.delete_endpoint(kind)

View File

@@ -1,4 +1,11 @@
<div class="settings-view-shell" data-testid="settings-editor" data-selected-settings-section={@settings_editor.selected_section}>
<div
id="settings-editor-shell"
class="settings-view-shell"
data-testid="settings-editor"
phx-hook="SettingsSectionScroll"
data-selected-settings-section={@settings_editor.selected_section}
data-settings-scroll-target={"settings-section-#{@settings_editor.selected_section}"}
>
<div class="settings-view">
<div class="settings-header">
<h2 data-testid="editor-title"><%= translated("Settings") %></h2>
@@ -222,6 +229,10 @@
<div class="setting-info"><label class="setting-label"><%= translated("Online Chat Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_chat_model]" value={@settings_editor.ai["online_chat_model"]} /></div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Online Chat Tools") %></label></div>
<div class="setting-control"><label><input type="checkbox" name="settings_ai[online_chat_tools]" checked={@settings_editor.ai["online_chat_tools"]} /> <%= translated("Enable tool calls for the online chat model") %></label></div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Online Title Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_title_model]" value={@settings_editor.ai["online_title_model"]} /></div>
@@ -251,6 +262,10 @@
<div class="setting-info"><label class="setting-label"><%= translated("Offline Chat Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_chat_model]" value={@settings_editor.ai["offline_chat_model"]} /></div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline Chat Tools") %></label></div>
<div class="setting-control"><label><input type="checkbox" name="settings_ai[offline_chat_tools]" checked={@settings_editor.ai["offline_chat_tools"]} /> <%= translated("Enable tool calls for the offline chat model") %></label></div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline Title Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_title_model]" value={@settings_editor.ai["offline_title_model"]} /></div>

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
@moduledoc false
alias BDS.Desktop.{FilePicker, ShellData}
alias BDS.AI
alias BDS.ImportDefinitions
alias BDS.Scripts
alias BDS.Templates
@@ -132,6 +133,27 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
end
end
def create(socket, _project_id, "chat", callbacks) do
case AI.start_chat(%{}) do
{:ok, conversation} ->
callbacks.open_sidebar.(
socket,
%{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => "AI conversations"
},
:pin
)
{:error, reason} ->
socket
|> callbacks.append_output.(translated("chat.newChat"), inspect(reason), nil, "error")
|> callbacks.reload.(socket.assigns.workbench)
end
end
def create(socket, project_id, "import", callbacks) do
case ImportDefinitions.create_definition(%{
project_id: project_id,
@@ -168,6 +190,7 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
def action(:media), do: %{kind: "media", label: "sidebar.importMedia"}
def action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"}
def action(:templates), do: %{kind: "template", label: "sidebar.templates.newTemplate"}
def action(:chat), do: %{kind: "chat", label: "chat.newChat"}
def action(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"}
def action(_view), do: nil

View File

@@ -0,0 +1,57 @@
defmodule BDS.Desktop.Shutdown do
@moduledoc false
alias BDS.Desktop.MainWindow
alias Desktop.Window
@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()
)
: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
defp start_shutdown_task do
Task.start(fn ->
MainWindow.persist_now()
quit_module().quit()
end)
:ok
end
defp quit_module do
Application.get_env(:bds, :desktop_window_quit_module, Window)
end
end

View File

@@ -18,7 +18,13 @@ defmodule BDS.MCP.Tools do
@proposal_ttl_app_ms 30 * 60 * 1000
@typedoc "Tool descriptor returned by `list/0`."
@type descriptor :: %{name: String.t(), annotations: map()}
@type descriptor :: %{
name: String.t(),
title: String.t(),
description: String.t(),
inputSchema: map(),
annotations: map()
}
@spec list() :: [descriptor()]
def list do
@@ -75,12 +81,269 @@ defmodule BDS.MCP.Tools do
end
defp tool(name, read_only) do
metadata = tool_metadata(name)
%{
name: name,
annotations: %{"readOnlyHint" => read_only, "destructiveHint" => false}
title: metadata.title,
description: metadata.description,
inputSchema: metadata.input_schema,
annotations: %{
"readOnlyHint" => read_only,
"destructiveHint" => false,
"openWorldHint" => false
}
}
end
defp tool_metadata("check_term") do
%{
title: "Check Term",
description:
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or count_posts when unsure whether a term is a category or tag.",
input_schema: object_schema(%{"term" => string_schema("The term to look up")}, ["term"])
}
end
defp tool_metadata("search_posts") do
%{
title: "Search Posts",
description:
"Search blog posts by query, category, tags, language, translation coverage, date, or status. Returns a paginated envelope with total, offset, limit, hasMore, and posts. Each post includes title, slug, tags, categories, backlinks, and linksTo. When hasMore is true, increase offset by limit. Use check_term first if unsure whether a term is a category or tag.",
input_schema: post_query_schema(false)
}
end
defp tool_metadata("count_posts") do
%{
title: "Count Posts",
description:
"Count posts grouped by year, month, tag, category, or status. Returns aggregated counts without full post data, useful for analytics, distributions, and heat maps. Example: groupBy=[\"month\",\"tag\"] with year=2004.",
input_schema:
object_schema(
Map.merge(group_filter_properties(), %{
"groupBy" => %{
"type" => "array",
"items" => enum_schema(["year", "month", "tag", "category", "status"]),
"description" => "Dimensions to group by; one to three dimensions is usually best"
}
}),
["groupBy"]
)
}
end
defp tool_metadata("read_post_by_slug") do
%{
title: "Read Post By Slug",
description:
"Read full content and metadata for a specific blog post by slug. Includes title, excerpt, content, status, tags, categories, backlinks, linksTo, and available languages. Optionally request a translation by language.",
input_schema:
object_schema(
%{
"slug" => string_schema("The slug of the post to read"),
"language" => string_schema("Optional language code for a translation")
},
["slug"]
)
}
end
defp tool_metadata("get_post_translations") do
%{
title: "Get Post Translations",
description:
"List all translations available for a blog post, including language, title, excerpt, content, and status.",
input_schema: object_schema(%{"postId" => string_schema("The post ID")}, ["postId"])
}
end
defp tool_metadata("get_media_translations") do
%{
title: "Get Media Translations",
description:
"List all available translations for media metadata, including language, title, alt text, and captions.",
input_schema: object_schema(%{"mediaId" => string_schema("The media ID")}, ["mediaId"])
}
end
defp tool_metadata("upsert_media_translation") do
%{
title: "Upsert Media Translation",
description: "Create or update translated media metadata for a specific language.",
input_schema:
object_schema(
%{
"mediaId" => string_schema("The media ID"),
"language" => string_schema("Language code to update"),
"title" => string_schema("Translated title"),
"alt" => string_schema("Translated alt text"),
"caption" => string_schema("Translated caption")
},
["mediaId", "language"]
)
}
end
defp tool_metadata("draft_post") do
%{
title: "Draft Post",
description: "Create a new draft blog post for review before publishing.",
input_schema:
object_schema(
%{
"title" => string_schema("Post title"),
"content" => string_schema("Post content in Markdown"),
"excerpt" => string_schema("Short excerpt or summary"),
"tags" => string_array_schema("Tags for the post"),
"categories" => string_array_schema("Categories for the post"),
"author" => string_schema("Post author name")
},
["title", "content"]
)
}
end
defp tool_metadata("propose_script") do
%{
title: "Propose Script",
description: "Propose a new Python script, macro, utility, or transform for review.",
input_schema:
object_schema(
%{
"title" => string_schema("Script title"),
"kind" => enum_schema(["macro", "utility", "transform"]),
"content" => string_schema("Python source code"),
"entrypoint" => string_schema("Entry point function name")
},
["title", "kind", "content"]
)
}
end
defp tool_metadata("propose_template") do
%{
title: "Propose Template",
description: "Propose a new Liquid template for review.",
input_schema:
object_schema(
%{
"title" => string_schema("Template title"),
"kind" => enum_schema(["post", "list", "not-found", "partial"]),
"content" => string_schema("Liquid template content")
},
["title", "kind", "content"]
)
}
end
defp tool_metadata("propose_media_metadata") do
%{
title: "Propose Media Metadata",
description:
"Propose changes to media metadata such as title, alt text, caption, and tags.",
input_schema:
object_schema(
%{
"mediaId" => string_schema("The media ID"),
"title" => string_schema("New title"),
"alt" => string_schema("New alt text"),
"caption" => string_schema("New caption"),
"tags" => string_array_schema("New tags")
},
["mediaId"]
)
}
end
defp tool_metadata("propose_post_metadata") do
%{
title: "Propose Post Metadata",
description:
"Propose changes to post metadata such as title, excerpt, tags, and categories.",
input_schema:
object_schema(
%{
"postId" => string_schema("The post ID"),
"title" => string_schema("New title"),
"excerpt" => string_schema("New excerpt"),
"tags" => string_array_schema("New tags"),
"categories" => string_array_schema("New categories")
},
["postId"]
)
}
end
defp tool_metadata("accept_proposal") do
%{
title: "Accept Proposal",
description: "Accept a pending proposal and apply or publish its changes.",
input_schema:
object_schema(%{"proposalId" => string_schema("The proposal ID")}, ["proposalId"])
}
end
defp tool_metadata("discard_proposal") do
%{
title: "Discard Proposal",
description: "Discard a pending proposal and remove any temporary draft artifacts.",
input_schema:
object_schema(%{"proposalId" => string_schema("The proposal ID")}, ["proposalId"])
}
end
defp post_query_schema(query_required) do
required = if query_required, do: ["query"], else: []
object_schema(
Map.merge(group_filter_properties(), %{
"query" => string_schema("Full-text search query"),
"language" => string_schema("Require posts available in this language"),
"missingTranslationLanguage" =>
string_schema("Require posts missing this translation language"),
"offset" => %{"type" => "integer", "minimum" => 0, "description" => "Pagination offset"},
"limit" => %{
"type" => "integer",
"minimum" => 1,
"maximum" => 50,
"description" => "Maximum results to return"
}
}),
required
)
end
defp group_filter_properties do
%{
"year" => %{"type" => "integer", "description" => "Filter to posts in this year"},
"month" => %{
"type" => "integer",
"minimum" => 1,
"maximum" => 12,
"description" => "Filter to posts in this month; requires year"
},
"status" => enum_schema(["draft", "published", "archived"]),
"category" => string_schema("Filter by category"),
"tags" => string_array_schema("Filter by tags; all must match")
}
end
defp object_schema(properties, required) do
%{"type" => "object", "properties" => properties}
|> maybe_schema_required(required)
end
defp maybe_schema_required(schema, []), do: schema
defp maybe_schema_required(schema, required), do: Map.put(schema, "required", required)
defp string_schema(description), do: %{"type" => "string", "description" => description}
defp string_array_schema(description),
do: %{"type" => "array", "items" => %{"type" => "string"}, "description" => description}
defp enum_schema(values), do: %{"type" => "string", "enum" => values}
defp check_term(%{"term" => term}), do: check_term(%{term: term})
defp check_term(%{term: term}) do

View File

@@ -3561,14 +3561,22 @@ button svg * {
.chat-panel-title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
color: var(--vscode-foreground, inherit);
}
.chat-panel-title-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-panel-header {
position: relative;
padding: 12px 16px;
@@ -5133,14 +5141,22 @@ button svg * {
.chat-panel-title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
color: var(--vscode-foreground, inherit);
}
.chat-panel-title-main {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-panel-header-actions {
display: flex;
align-items: center;
@@ -5160,9 +5176,29 @@ button svg * {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 1 auto;
max-width: min(40vw, 240px);
padding: 4px 8px;
font-size: 12px;
color: var(--vscode-descriptionForeground, inherit);
white-space: nowrap;
}
.chat-model-selector-inline {
min-width: 0;
background-color: var(--vscode-input-background, transparent);
}
.chat-model-selector-inline span:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.chat-model-selector-wrap {
position: relative;
display: inline-flex;
min-width: 0;
flex: 0 1 auto;
}
.chat-model-selector-button:hover,
@@ -5177,7 +5213,7 @@ button svg * {
.chat-model-selector-menu {
position: absolute;
top: calc(100% + 4px);
right: 16px;
left: 0;
min-width: 180px;
max-height: 300px;
overflow-y: auto;
@@ -5318,6 +5354,13 @@ button svg * {
}
.chat-message.user .chat-message-content {
width: fit-content;
min-width: 0;
max-width: min(80%, 720px);
display: flex;
flex-direction: column;
align-items: flex-end;
margin-left: auto;
text-align: right;
}
@@ -5368,6 +5411,9 @@ button svg * {
}
.chat-message.user .chat-message-text {
width: fit-content;
max-width: 100%;
display: inline-block;
border-radius: 12px 12px 2px 12px;
background-color: var(--vscode-button-background, var(--accent-color));
color: var(--vscode-button-foreground, #ffffff);
@@ -5377,6 +5423,74 @@ button svg * {
white-space: normal;
}
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
width: auto;
min-width: 0;
max-width: 240px;
height: auto;
flex: 0 1 auto;
padding: 4px 8px;
border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c));
border-radius: 4px;
background: transparent;
color: var(--vscode-descriptionForeground, inherit);
}
.chat-panel .chat-model-selector-caret {
position: static;
inset: auto;
width: auto;
min-width: 0;
max-width: none;
height: auto;
display: inline;
flex: 0 0 auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: inherit;
font-size: 10px;
line-height: 1;
}
.chat-panel .chat-model-selector-menu {
left: 0;
right: auto;
width: max-content;
min-width: 180px;
max-width: min(360px, calc(100vw - 48px));
height: auto;
padding: 6px;
border: 1px solid var(--vscode-dropdown-border, var(--line, #3c3c3c));
border-radius: 4px;
background: var(--vscode-dropdown-background, var(--panel-1, #1e1e1e));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.chat-panel .chat-message.user .chat-message-content {
width: max-content;
min-width: 0;
max-width: min(72%, 720px);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.chat-panel .chat-message.user .chat-message-text.chat-user-message-text {
width: auto;
min-width: 0;
max-width: 100%;
display: inline-block;
box-sizing: border-box;
padding: 6px 12px;
line-height: 1.35;
white-space: pre-wrap;
}
.chat-message.streaming .chat-message-text {
background: linear-gradient(
90deg,
@@ -5682,11 +5796,15 @@ button svg * {
}
.chat-input-container {
padding: 16px;
padding: 8px 16px;
border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e));
}
.chat-panel .chat-input-container {
padding: 8px 16px;
}
.chat-abort-button {
display: block;
width: 100%;
@@ -5704,36 +5822,46 @@ button svg * {
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;
align-items: flex-end;
gap: 8px;
padding: 8px;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 4px 6px;
border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c));
border-radius: 8px;
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));
}
.chat-input {
.chat-panel .chat-input {
flex: 1;
min-height: 24px;
max-height: 200px;
display: block;
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;
border: none;
outline: none;
appearance: none;
background: transparent;
color: var(--vscode-input-foreground, inherit);
font: inherit;
line-height: 1.5;
line-height: var(--chat-input-line-height);
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));
}
@@ -5747,10 +5875,16 @@ button svg * {
font-size: 12px;
}
.chat-send-button {
.chat-panel .chat-send-button {
flex-shrink: 0;
width: 32px;
height: 32px;
box-sizing: border-box;
width: 22px;
height: 22px;
min-width: 22px;
min-height: 22px;
max-width: 22px;
max-height: 22px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -5758,16 +5892,17 @@ button svg * {
border-radius: 50%;
background-color: var(--vscode-button-background, var(--accent-color));
color: var(--vscode-button-foreground, #ffffff);
font-size: 18px;
font-size: 15px;
line-height: 1;
cursor: pointer;
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));
}
.chat-send-button:disabled,
.chat-panel .chat-send-button:disabled,
.api-key-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -5945,6 +6080,10 @@ button svg * {
.chat-input-container {
padding: 12px;
}
.chat-panel .chat-input-container {
padding: 6px 8px;
}
}
@media (max-width: 720px) {

View File

@@ -690,6 +690,35 @@ document.addEventListener("DOMContentLoaded", () => {
}
},
SettingsSectionScroll: {
mounted() {
this.lastTargetId = null;
this.scrollToSelectedSection();
},
updated() {
this.scrollToSelectedSection();
},
scrollToSelectedSection() {
const targetId = this.el.dataset.settingsScrollTarget;
if (!targetId || targetId === this.lastTargetId) {
return;
}
this.lastTargetId = targetId;
window.requestAnimationFrame(() => {
const target = document.getElementById(targetId);
if (target && this.el.contains(target)) {
target.scrollIntoView({ block: "start", behavior: "smooth" });
}
});
}
},
ChatSurface: {
mounted() {
this.stickToBottom = true;
@@ -702,8 +731,25 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
const styles = getComputedStyle(textarea);
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 = () => {

View File

@@ -203,7 +203,9 @@ defmodule BDS.AITest do
url: "https://api.example.test/v1",
api_key: "top-secret",
model: "gpt-4o-mini"
}, secret_backend: FakeSecretBackend)
},
secret_backend: FakeSecretBackend
)
assert endpoint.kind == :online
assert endpoint.url == "https://api.example.test/v1"
@@ -316,7 +318,9 @@ defmodule BDS.AITest do
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
}, secret_backend: FakeSecretBackend)
},
secret_backend: FakeSecretBackend
)
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
@@ -325,7 +329,9 @@ defmodule BDS.AITest do
url: "http://localhost:11434/v1",
api_key: nil,
model: "llama-default"
}, secret_backend: FakeSecretBackend)
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(true)
assert :ok = BDS.AI.put_model_preference(:airplane_title, "llama3.1")
@@ -354,7 +360,9 @@ defmodule BDS.AITest do
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
}, secret_backend: FakeSecretBackend)
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(false)
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
@@ -389,7 +397,9 @@ defmodule BDS.AITest do
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
}, secret_backend: FakeSecretBackend)
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(false)
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
@@ -421,7 +431,9 @@ defmodule BDS.AITest do
url: "http://localhost:11434/v1",
api_key: nil,
model: "llama-default"
}, secret_backend: FakeSecretBackend)
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(true)
assert :ok = BDS.AI.put_model_preference(:airplane_image_analysis, "llama3.2")
@@ -434,7 +446,11 @@ defmodule BDS.AITest do
alt: nil,
caption: nil,
image_url: "file:///tmp/test.png"
}, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend)
},
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend
)
assert :ok =
BDS.AI.put_model_capabilities("llama3.2", %{
@@ -450,7 +466,11 @@ defmodule BDS.AITest do
alt: nil,
caption: nil,
image_url: "file:///tmp/test.png"
}, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend)
},
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend
)
assert analysis.alt == "Orange sunset over calm water"
@@ -462,7 +482,7 @@ defmodule BDS.AITest 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 = seed_project_content(project.id)
_fixtures = seed_project_content(project.id)
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
@@ -471,7 +491,9 @@ defmodule BDS.AITest do
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
}, secret_backend: FakeSecretBackend)
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(false)
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
@@ -506,10 +528,203 @@ defmodule BDS.AITest do
assert Enum.any?(first_request.messages, fn message ->
message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and
String.contains?(message["content"], "Media: 1")
String.contains?(message["content"], "Media: 1") 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"], "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)
tool_descriptions =
first_request.tools
|> Map.new(fn tool ->
{get_in(tool, ["function", "name"]), get_in(tool, ["function", "description"])}
end)
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"] =~ "URLs"
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"] == "assistant" and
message["tool_calls"] == [
%{
"id" => "call-blog-stats",
"type" => "function",
"function" => %{"name" => "blog_stats", "arguments" => "{}"}
}
]
end)
end
test "chat does not prompt models to emit textual tool calls when tools are unavailable" do
{:ok, project} = create_project_fixture("No Tool Chat")
_fixtures = seed_project_content(project.id)
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:airplane,
%{
url: "http://localhost:11434/v1",
api_key: nil,
model: "llama-plain"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(true)
assert :ok = BDS.AI.put_model_preference(:airplane_chat, "llama-plain")
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "llama-plain"})
assert {:ok, _reply} =
BDS.AI.send_chat_message(conversation.id, "Show posts per month",
runtime: FakeRuntime,
test_pid: self(),
project_id: project.id,
secret_backend: FakeSecretBackend
)
assert_received {:runtime_request, _endpoint, first_request}
assert first_request.tools == []
refute Enum.any?(first_request.messages, fn message ->
message["role"] == "system" and
String.contains?(message["content"], "Available blog data tools")
end)
end
test "non-stat chat tools expose concrete project data" do
{:ok, project} = create_project_fixture("Concrete Tools")
%{post: post, media: media} = seed_project_content(project.id)
assert %{posts: [listed_post], total: 1} =
BDS.AI.ChatTools.execute("list_posts", %{"limit" => 5}, project.id)
assert listed_post["title"] == post.title
assert listed_post["slug"] == post.slug
assert listed_post["url"] == "/posts/#{post.slug}"
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.filename == "image.png"
assert listed_media.mime_type == "image/png"
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
test "cancel_chat aborts an in-flight chat turn" do
@@ -520,7 +735,9 @@ defmodule BDS.AITest do
url: "https://api.example.test/v1",
api_key: "online-secret",
model: "gpt-4o-mini"
}, secret_backend: FakeSecretBackend)
},
secret_backend: FakeSecretBackend
)
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
@@ -558,6 +775,7 @@ defmodule BDS.AITest do
defp seed_project_content(project_id) do
now = Persistence.now_ms()
post =
Repo.insert!(
Post.changeset(%Post{}, %{
id: Ecto.UUID.generate(),
@@ -573,6 +791,7 @@ defmodule BDS.AITest do
})
)
media =
Repo.insert!(
Media.changeset(%Media{}, %{
id: Ecto.UUID.generate(),
@@ -581,6 +800,7 @@ defmodule BDS.AITest do
original_name: "image.png",
mime_type: "image/png",
size: 128,
title: "Hero",
file_path: "media/image.png",
sidecar_path: "media/image.png.meta",
created_at: now,
@@ -588,6 +808,6 @@ defmodule BDS.AITest do
})
)
:ok
%{post: post, media: media}
end
end

View File

@@ -44,6 +44,11 @@ defmodule BDS.Desktop.MainWindowTest do
assert MainWindow.restore_bounds() == %{x: 120, y: 80, width: 1260, height: 820}
end
test "window id and watcher process name do not collide" do
assert MainWindow.window_id() == BDS.Desktop.MainWindow
assert MainWindow.server_name() == BDS.Desktop.MainWindow.Watcher
end
test "window options clamp oversized startup bounds to the visible client area" do
desktop = Application.get_env(:bds, :desktop, [])
@@ -63,4 +68,37 @@ defmodule BDS.Desktop.MainWindowTest do
assert opts[:size] == {1200, 700}
end
test "terminate persists the last known bounds without querying wx during shutdown", %{
path: path
} do
bounds = %{x: 33, y: 44, width: 900, height: 700}
assert :ok = MainWindow.terminate(:shutdown, %{frame: :invalid_wx_frame, last_bounds: bounds})
assert Jason.decode!(File.read!(path)) == %{
"x" => 33,
"y" => 44,
"width" => 900,
"height" => 700
}
end
test "persist timer keeps last bounds when the wx frame is already gone", %{path: path} do
bounds = %{x: 166, y: 57, width: 1280, height: 780}
assert {:noreply, state} =
MainWindow.handle_info(:persist_bounds, %{
frame: :invalid_wx_frame,
last_bounds: bounds
})
assert state.last_bounds == bounds
refute File.exists?(path)
end
test "persist now is harmless when the window watcher is not running" do
assert :ok = MainWindow.persist_now()
end
end

View File

@@ -136,7 +136,7 @@ defmodule BDS.Desktop.ShellLiveTest do
%{project: project, temp_dir: temp_dir}
end
test "sidebar headers expose old-app create actions for posts, media, scripts, templates, and imports" do
test "sidebar headers expose old-app create actions for posts, media, scripts, templates, chat, and imports" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-create-action")
@@ -162,6 +162,13 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-sidebar-action="template")
html =
view
|> element("[data-testid='activity-button'][data-view='chat']")
|> render_click()
assert html =~ ~s(data-sidebar-action="chat")
html =
view
|> element("[data-testid='activity-button'][data-view='import']")
@@ -170,13 +177,15 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-sidebar-action="import")
end
test "sidebar create actions follow the old-app post, script, template, and import flows", %{
test "sidebar create actions follow the old-app post, script, template, chat, and import flows",
%{
project: project
} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
post_count_before = Repo.aggregate(Post, :count, :id)
script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id)
template_count_before = Repo.aggregate(BDS.Templates.Template, :count, :id)
chat_count_before = Repo.aggregate(BDS.AI.ChatConversation, :count, :id)
import_count_before = Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id)
html =
@@ -225,6 +234,20 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-type="templates")
assert html =~ ~s(data-tab-id="#{created_template.id}")
_html = render_click(view, "select_view", %{"view" => "chat"})
html =
view
|> element("[data-testid='sidebar-create-action'][data-sidebar-action='chat']")
|> render_click()
assert Repo.aggregate(BDS.AI.ChatConversation, :count, :id) == chat_count_before + 1
created_chat = Repo.one!(BDS.AI.ChatConversation)
assert created_chat.title == "New Chat"
assert html =~ ~s(data-tab-type="chat")
assert html =~ ~s(data-tab-id="#{created_chat.id}")
_html = render_click(view, "select_view", %{"view" => "import"})
html =
@@ -242,6 +265,21 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-id="#{created_definition.id}")
end
test "settings sidebar selections expose a scroll target for the preferences editor" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html = render_click(view, "select_view", %{"view" => "settings"})
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='settings-ai']")
|> render_click()
assert html =~ ~s(phx-hook="SettingsSectionScroll")
assert html =~ ~s(data-selected-settings-section="ai")
assert html =~ ~s(data-settings-scroll-target="settings-section-ai")
end
test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change",
%{project: project} do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
@@ -732,11 +770,13 @@ defmodule BDS.Desktop.ShellLiveTest do
"online_url" => "https://api.example.test/v1",
"online_api_key" => "online-secret",
"online_chat_model" => "gpt-4.1",
"online_chat_tools" => "true",
"online_title_model" => "gpt-4.1-mini",
"online_image_analysis_model" => "gpt-4.1-vision",
"offline_url" => "http://localhost:11434/v1",
"offline_api_key" => "",
"offline_chat_model" => "llama3.3",
"offline_chat_tools" => "true",
"offline_title_model" => "llama3.2",
"offline_image_analysis_model" => "llava:latest",
"offline_mode" => "true",
@@ -764,6 +804,9 @@ defmodule BDS.Desktop.ShellLiveTest do
assert {:ok, "llama3.3"} = AI.get_model_preference(:airplane_chat)
assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title)
assert {:ok, "llava:latest"} = AI.get_model_preference(:airplane_image_analysis)
assert %{supports_tool_calls: true} = BDS.AI.Catalog.model_capabilities("gpt-4.1")
assert %{supports_tool_calls: true} = BDS.AI.Catalog.model_capabilities("llama3.3")
end
test "ai settings refresh models from the configured endpoints" do
@@ -2074,6 +2117,38 @@ defmodule BDS.Desktop.ShellLiveTest do
refute chat_html =~ "Desktop workbench content routed through the Elixir shell."
end
test "chat editor uses the model name itself as the selector" do
assert {:ok, conversation} = AI.start_chat(%{title: "Selector Chat", model: "qwen3.5-122b"})
{: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(data-testid="chat-model-selector-button")
assert html =~ ~s(class="chat-panel-title-main")
assert html =~ ~s(class="chat-model-selector-wrap")
assert html =~ ~s(class="chat-model-selector-button chat-model-selector-inline")
refute html =~ ~s(class="chat-panel-header-actions")
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__))
assert css =~ ".chat-model-selector-wrap"
assert css =~ "left: 0;"
assert css =~ "right: auto;"
refute css =~
".chat-model-selector-menu {\n position: absolute;\n top: calc(100% + 4px);\n right: 16px;"
assert css =~ ".chat-panel .chat-model-selector-button.chat-model-selector-inline"
assert css =~ ".chat-panel .chat-model-selector-caret"
assert css =~ "position: static;"
end
test "chat editor renders legacy model controls, tool markers, and structured tool surfaces" do
assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat", model: "gpt-4.1"})
@@ -2141,6 +2216,161 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Posts"
end
test "chat editor folds tool-only assistant steps into the final assistant answer" do
assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})
now = Persistence.now_ms()
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :user,
content: "Show posts per month",
created_at: now
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: nil,
tool_calls:
Jason.encode!([
%{
"id" => "call-count-posts",
"name" => "count_posts",
"arguments" => %{"groupBy" => ["month"], "year" => 2026}
}
]),
created_at: now + 1
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :tool,
tool_call_id: "call-count-posts",
content: Jason.encode!([%{"month" => 5, "count" => 3}]),
created_at: now + 2
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "Here is the chart.",
created_at: now + 3
})
)
{: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 =~ "count_posts"
assert html =~ "Here is the chart."
assert html =~ ~s(<span class="chat-message-role">Assistant</span>)
assert length(:binary.matches(html, ~s(<span class="chat-message-role">Assistant</span>))) == 1
end
test "chat editor marks user message text as compact" do
assert {:ok, conversation} = AI.start_chat(%{title: "Compact Chat", model: "gpt-4.1"})
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :user,
content: "wie viele Posts sind im Blog?",
created_at: Persistence.now_ms()
})
)
{: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(data-testid="chat-user-message-text")
assert html =~ ~s(class="chat-message-text chat-user-message-text")
assert html =~
~s(<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text">wie viele Posts sind im Blog?</div>)
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__))
assert css =~ ".chat-panel .chat-message.user .chat-message-content"
assert css =~ "background: transparent;"
assert css =~ "border: 0;"
assert css =~ "padding: 6px 12px;"
assert css =~ "line-height: 1.35;"
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
updated_at = Persistence.now_ms()

View File

@@ -3,6 +3,20 @@ defmodule BDS.DesktopTest do
import Plug.Test
defmodule FakeShutdown do
def request_quit do
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :quit_requested)
:ok
end
end
defmodule FakeWindowQuit do
def quit do
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :window_quit_requested)
:ok
end
end
test "desktop configuration no longer uses a pending adapter" do
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
end
@@ -99,6 +113,61 @@ defmodule BDS.DesktopTest do
assert menu_item(groups, :metadata_diff).shortcut == nil
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 "cmd-q remains handled by the desktop window quit handler" do
refute function_exported?(BDS.Desktop.Shutdown, :command_menu_selected, 2)
end
test "app-owned shutdown delegates final termination to the desktop hard quit path" do
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
previous_quit_module = Application.get_env(:bds, :desktop_window_quit_module)
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)
Application.put_env(:bds, :desktop_shutdown_module, BDS.Desktop.Shutdown)
Application.put_env(:bds, :desktop_window_quit_module, FakeWindowQuit)
Application.put_env(:bds, :desktop_shutdown_test_pid, self())
on_exit(fn ->
restore_env(:desktop_shutdown_module, previous_module)
restore_env(:desktop_window_quit_module, previous_quit_module)
restore_env(:desktop_shutdown_test_pid, previous_pid)
end)
assert :ok = BDS.Desktop.Shutdown.request_quit()
assert_receive :window_quit_requested
end
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 = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
@@ -178,4 +247,7 @@ defmodule BDS.DesktopTest do
Image.new!(3, 2, color: [255, 0, 0])
|> Image.write!(:memory, suffix: ".jpg", quality: 85)
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

View File

@@ -21,9 +21,8 @@ defmodule BDS.MCPTest do
end
test "list_tools follows the old app tool surface for implemented backend features" do
tool_names =
BDS.MCP.list_tools()
|> Enum.map(& &1.name)
tools = BDS.MCP.list_tools()
tool_names = Enum.map(tools, & &1.name)
assert "check_term" in tool_names
assert "search_posts" in tool_names
@@ -39,6 +38,20 @@ defmodule BDS.MCPTest do
assert "propose_post_metadata" in tool_names
assert "accept_proposal" in tool_names
assert "discard_proposal" in tool_names
search_posts = Enum.find(tools, &(&1.name == "search_posts"))
assert search_posts.title == "Search Posts"
assert search_posts.description =~ "paginated envelope"
assert search_posts.description =~ "backlinks"
assert get_in(search_posts.inputSchema, ["properties", "query", "description"]) =~ "Full-text"
assert get_in(search_posts.inputSchema, ["properties", "tags", "items", "type"]) == "string"
assert search_posts.annotations["readOnlyHint"] == true
assert search_posts.annotations["openWorldHint"] == false
draft_post = Enum.find(tools, &(&1.name == "draft_post"))
assert draft_post.description =~ "draft blog post"
assert draft_post.inputSchema["required"] == ["title", "content"]
assert draft_post.annotations["readOnlyHint"] == false
end
test "check_term, search_posts, count_posts, and read_post_by_slug expose current blog data", %{