Compare commits
6 Commits
8a582ee6c7
...
391a7f216f
| Author | SHA1 | Date | |
|---|---|---|---|
| 391a7f216f | |||
| c25720bf6e | |||
| e4db1d6d62 | |||
| f8b8ccabbd | |||
| b5ebea6ff2 | |||
| dd0c05b785 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
57
lib/bds/desktop/shutdown.ex
Normal file
57
lib/bds/desktop/shutdown.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
185
priv/ui/app.css
185
priv/ui/app.css
@@ -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) {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", %{
|
||||
|
||||
Reference in New Issue
Block a user