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
|
end
|
||||||
|
|
||||||
defp build_chat_request(conversation, messages, model, project_id, tools) do
|
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,
|
operation: :chat,
|
||||||
@@ -479,10 +479,44 @@ defmodule BDS.AI.Chat do
|
|||||||
|
|
||||||
case Catalog.decode_nullable_json(message.tool_calls) do
|
case Catalog.decode_nullable_json(message.tool_calls) do
|
||||||
nil -> base
|
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
|
||||||
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
|
defp truncate_chat_messages(messages, model, tools) do
|
||||||
context_window = model_context_window(model)
|
context_window = model_context_window(model)
|
||||||
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))
|
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))
|
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
|
||||||
end
|
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
|
base = get_setting("ai.system_prompt") || @default_system_prompt
|
||||||
|
|
||||||
case project_stats_summary(project_id) do
|
with true <- tools != [],
|
||||||
nil -> base
|
summary when is_binary(summary) <- project_stats_summary(project_id) do
|
||||||
summary -> base <> "\n\nCurrent blog statistics:\n" <> summary
|
base <> "\n\nCurrent blog statistics:\n" <> summary <> "\n\n" <> blog_tool_guidance()
|
||||||
|
else
|
||||||
|
_other -> base
|
||||||
end
|
end
|
||||||
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(nil), do: nil
|
||||||
|
|
||||||
defp project_stats_summary(project_id) do
|
defp project_stats_summary(project_id) do
|
||||||
|
|||||||
@@ -4,13 +4,22 @@ defmodule BDS.AI.ChatTools do
|
|||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
|
alias BDS.Media, as: MediaContext
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
|
alias BDS.MCP.Queries
|
||||||
|
alias BDS.Posts, as: PostsContext
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Posts.PostMedia
|
||||||
alias BDS.Projects.Project
|
alias BDS.Projects.Project
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
alias BDS.Search
|
||||||
|
|
||||||
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
||||||
def execute("blog_stats", _arguments, project_id) do
|
def execute("blog_stats", _arguments, project_id) do
|
||||||
|
execute("get_blog_stats", %{}, project_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_blog_stats", _arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
%{
|
%{
|
||||||
@@ -23,20 +32,91 @@ defmodule BDS.AI.ChatTools do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("list_posts", arguments, project_id) do
|
def execute("check_term", arguments, project_id) do
|
||||||
limit = normalize_limit(arguments["limit"])
|
project_id = project_id || active_project_id()
|
||||||
|
term = normalize_term(arguments["term"])
|
||||||
|
|
||||||
Repo.all(
|
posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
|
||||||
from(post in Post,
|
|
||||||
where: post.project_id == ^project_id,
|
tag_post_count =
|
||||||
order_by: [desc: post.updated_at],
|
Enum.count(posts, fn post ->
|
||||||
limit: ^limit,
|
Enum.any?(post.tags || [], &(normalize_term(&1) == term))
|
||||||
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status}
|
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
|
end
|
||||||
|
|
||||||
def execute("list_media", arguments, project_id) do
|
def execute("list_media", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
limit = normalize_limit(arguments["limit"])
|
limit = normalize_limit(arguments["limit"])
|
||||||
|
|
||||||
Repo.all(
|
Repo.all(
|
||||||
@@ -48,12 +128,160 @@ defmodule BDS.AI.ChatTools do
|
|||||||
id: media.id,
|
id: media.id,
|
||||||
title: media.title,
|
title: media.title,
|
||||||
mime_type: media.mime_type,
|
mime_type: media.mime_type,
|
||||||
filename: media.filename
|
filename: media.filename,
|
||||||
|
updated_at: media.updated_at
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def execute("get_media", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Media,
|
||||||
|
id: arguments["mediaId"] || arguments["media_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Media{} = media -> %{media: media_summary(media)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("view_image", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
media_id = arguments["mediaId"] || arguments["media_id"]
|
||||||
|
size = arguments["size"] || "medium"
|
||||||
|
|
||||||
|
case Repo.get_by(Media, id: media_id, project_id: project_id) do
|
||||||
|
%Media{mime_type: "image/" <> _rest} = media ->
|
||||||
|
case thumbnail_data_url(project_id, media, size) do
|
||||||
|
nil -> %{success: false, error: "thumbnail_not_available"}
|
||||||
|
data_url -> %{success: true, media: media_summary(media), data_url: data_url}
|
||||||
|
end
|
||||||
|
|
||||||
|
%Media{} = media ->
|
||||||
|
%{success: false, error: "not_image", mime_type: media.mime_type}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
%{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("update_post_metadata", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
post_id = arguments["postId"] || arguments["post_id"]
|
||||||
|
|
||||||
|
with %Post{} <- Repo.get_by(Post, id: post_id, project_id: project_id),
|
||||||
|
attrs <- metadata_attrs(arguments, ["title", "excerpt", "tags", "categories"]),
|
||||||
|
false <- attrs == %{},
|
||||||
|
{:ok, post} <- PostsContext.update_post(post_id, attrs) do
|
||||||
|
%{success: true, post: Queries.post_detail(post)}
|
||||||
|
else
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
true -> %{success: false, error: "no_updates_provided"}
|
||||||
|
{:error, reason} -> %{success: false, error: inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("update_media_metadata", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
media_id = arguments["mediaId"] || arguments["media_id"]
|
||||||
|
|
||||||
|
with %Media{} <- Repo.get_by(Media, id: media_id, project_id: project_id),
|
||||||
|
attrs <- metadata_attrs(arguments, ["title", "alt", "caption", "tags"]),
|
||||||
|
false <- attrs == %{},
|
||||||
|
{:ok, media} <- MediaContext.update_media(media_id, attrs) do
|
||||||
|
%{success: true, media: media_summary(media)}
|
||||||
|
else
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
true -> %{success: false, error: "no_updates_provided"}
|
||||||
|
{:error, reason} -> %{success: false, error: inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("list_tags", _arguments, project_id) do
|
||||||
|
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
|
def execute("render_table", arguments, _project_id) do
|
||||||
%{
|
%{
|
||||||
type: "table",
|
type: "table",
|
||||||
@@ -67,7 +295,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
type: "chart",
|
type: "chart",
|
||||||
title: arguments["title"],
|
title: arguments["title"],
|
||||||
chart_type: arguments["chart_type"] || "bar",
|
chart_type: arguments["chartType"] || arguments["chart_type"] || "bar",
|
||||||
series: arguments["series"] || []
|
series: arguments["series"] || []
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -142,19 +370,184 @@ defmodule BDS.AI.ChatTools do
|
|||||||
"properties" => %{}
|
"properties" => %{}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
%{
|
||||||
|
name: "get_blog_stats",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_blog_stats",
|
||||||
|
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
|
||||||
|
%{"type" => "object", "properties" => %{}}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "check_term",
|
||||||
|
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",
|
name: "list_posts",
|
||||||
spec:
|
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",
|
name: "list_media",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"list_media",
|
"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()
|
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
|
else
|
||||||
@@ -166,14 +559,18 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "render_card",
|
name: "render_card",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("render_card", "Return a structured card payload", render_card_schema())
|
tool_spec(
|
||||||
|
"render_card",
|
||||||
|
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
|
||||||
|
render_card_schema()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_table",
|
name: "render_table",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"render_table",
|
"render_table",
|
||||||
"Return a structured table payload",
|
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
|
||||||
render_table_schema()
|
render_table_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -182,40 +579,52 @@ defmodule BDS.AI.ChatTools do
|
|||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"render_chart",
|
"render_chart",
|
||||||
"Return a structured chart payload",
|
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
|
||||||
render_chart_schema()
|
render_chart_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_form",
|
name: "render_form",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("render_form", "Return a structured form payload", render_form_schema())
|
tool_spec(
|
||||||
|
"render_form",
|
||||||
|
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
|
||||||
|
render_form_schema()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_metric",
|
name: "render_metric",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"render_metric",
|
"render_metric",
|
||||||
"Return a structured metric payload",
|
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
|
||||||
render_metric_schema()
|
render_metric_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_list",
|
name: "render_list",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("render_list", "Return a structured list payload", render_list_schema())
|
tool_spec(
|
||||||
|
"render_list",
|
||||||
|
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
|
||||||
|
render_list_schema()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_tabs",
|
name: "render_tabs",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())
|
tool_spec(
|
||||||
|
"render_tabs",
|
||||||
|
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
|
||||||
|
render_tabs_schema()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_mindmap",
|
name: "render_mindmap",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"render_mindmap",
|
"render_mindmap",
|
||||||
"Return a structured mindmap payload",
|
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
|
||||||
render_mindmap_schema()
|
render_mindmap_schema()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -245,13 +654,106 @@ defmodule BDS.AI.ChatTools do
|
|||||||
}
|
}
|
||||||
end
|
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
|
defp render_table_schema do
|
||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"title" => %{"type" => "string", "description" => "Optional table title"},
|
||||||
"columns" => %{"type" => "array"},
|
"columns" => %{
|
||||||
"rows" => %{"type" => "array"}
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "Column header names"
|
||||||
|
},
|
||||||
|
"rows" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"description" => "Table rows, each row is an array of cell values"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -260,10 +762,40 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"chartType" => %{
|
||||||
"chart_type" => %{"type" => "string"},
|
"type" => "string",
|
||||||
"series" => %{"type" => "array"}
|
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||||
}
|
"description" =>
|
||||||
|
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
|
||||||
|
},
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional chart title"},
|
||||||
|
"series" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Array of data points.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Data point label"},
|
||||||
|
"value" => %{"type" => "number", "description" => "Data point value"},
|
||||||
|
"segments" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" =>
|
||||||
|
"Segments within this data point. Required for stacked-bar and heatmap charts.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string"},
|
||||||
|
"value" => %{"type" => "number"}
|
||||||
|
},
|
||||||
|
"required" => ["label", "value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["chartType", "series"]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -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) when is_integer(value) and value > 0 and value <= 50, do: value
|
||||||
defp normalize_limit(_value), do: 10
|
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
|
defp active_project_id do
|
||||||
Repo.one(from(project in Project, where: project.is_active == true, select: project.id))
|
Repo.one(from(project in Project, where: project.is_active == true, select: project.id))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,16 +6,24 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
alias Desktop.Window
|
alias Desktop.Window
|
||||||
|
|
||||||
@window_id __MODULE__
|
@window_id __MODULE__
|
||||||
|
@server_name BDS.Desktop.MainWindow.Watcher
|
||||||
@persist_interval_ms 1_000
|
@persist_interval_ms 1_000
|
||||||
@default_size {1280, 780}
|
@default_size {1280, 780}
|
||||||
@default_min_size {800, 600}
|
@default_min_size {800, 600}
|
||||||
@state_file "window-state.json"
|
@state_file "window-state.json"
|
||||||
|
|
||||||
def start_link(_opts) do
|
def start_link(_opts) do
|
||||||
GenServer.start_link(__MODULE__, :ok)
|
GenServer.start_link(__MODULE__, :ok, name: @server_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def window_id, do: @window_id
|
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
|
def window_options(extra_opts \\ []) do
|
||||||
desktop_config = Application.get_env(:bds, :desktop, [])
|
desktop_config = Application.get_env(:bds, :desktop, [])
|
||||||
@@ -71,6 +79,7 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
|
|
||||||
frame ->
|
frame ->
|
||||||
apply_restored_bounds(frame)
|
apply_restored_bounds(frame)
|
||||||
|
BDS.Desktop.Shutdown.install_handlers(frame)
|
||||||
schedule_persist()
|
schedule_persist()
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
@@ -90,8 +99,13 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def terminate(_reason, %{frame: frame, last_bounds: last_bounds}) do
|
def handle_call(:persist_bounds_now, _from, state) do
|
||||||
if bounds = current_bounds(frame) || last_bounds 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)
|
_ = persist_bounds(bounds)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -102,6 +116,16 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
Process.send_after(self(), :persist_bounds, @persist_interval_ms)
|
Process.send_after(self(), :persist_bounds, @persist_interval_ms)
|
||||||
end
|
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
|
defp apply_restored_bounds(frame) do
|
||||||
case restore_bounds() do
|
case restore_bounds() do
|
||||||
%{x: x, y: y, width: width, height: height} ->
|
%{x: x, y: y, width: width, height: height} ->
|
||||||
@@ -126,23 +150,30 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
defp current_bounds(nil), do: nil
|
defp current_bounds(nil), do: nil
|
||||||
|
|
||||||
defp current_bounds(frame) do
|
defp current_bounds(frame) do
|
||||||
with_wx_env(fn ->
|
try do
|
||||||
cond do
|
with_wx_env(fn ->
|
||||||
not :wxWindow.isShown(frame) ->
|
cond do
|
||||||
nil
|
not :wxWindow.isShown(frame) ->
|
||||||
|
nil
|
||||||
|
|
||||||
:wxTopLevelWindow.isFullScreen(frame) ->
|
:wxTopLevelWindow.isFullScreen(frame) ->
|
||||||
nil
|
nil
|
||||||
|
|
||||||
:wxTopLevelWindow.isMaximized(frame) ->
|
:wxTopLevelWindow.isMaximized(frame) ->
|
||||||
nil
|
nil
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
{x, y} = :wxWindow.getPosition(frame)
|
{x, y} = :wxWindow.getPosition(frame)
|
||||||
{width, height} = :wxWindow.getSize(frame)
|
{width, height} = :wxWindow.getSize(frame)
|
||||||
%{x: x, y: y, width: width, height: height}
|
%{x: x, y: y, width: width, height: height}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
rescue
|
||||||
|
ErlangError -> nil
|
||||||
|
FunctionClauseError -> nil
|
||||||
|
catch
|
||||||
|
:exit, _reason -> nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp with_wx_env(fun) do
|
defp with_wx_env(fun) do
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Menu do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use BDS.Desktop.MenuCompat
|
use BDS.Desktop.MenuCompat
|
||||||
|
alias BDS.Desktop.Shutdown
|
||||||
alias Desktop.Window
|
alias Desktop.Window
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -27,7 +28,7 @@ defmodule BDS.Desktop.Menu do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("quit", menu) do
|
def handle_event("quit", menu) do
|
||||||
Window.quit()
|
Shutdown.request_quit()
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use BDS.Desktop.MenuCompat
|
use BDS.Desktop.MenuCompat
|
||||||
|
alias BDS.Desktop.Shutdown
|
||||||
alias BDS.UI.Commands
|
alias BDS.UI.Commands
|
||||||
alias BDS.UI.MenuBar, as: ShellMenuBar
|
alias BDS.UI.MenuBar, as: ShellMenuBar
|
||||||
alias Desktop.OS
|
alias Desktop.OS
|
||||||
@@ -50,7 +51,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("quit", menu) do
|
def handle_event("quit", menu) do
|
||||||
Window.quit()
|
Shutdown.request_quit()
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
next_turn_index = turn_index + 1
|
next_turn_index = turn_index + 1
|
||||||
{entries, start_entry(message, next_turn_index, assigns), next_turn_index}
|
{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 ->
|
_other ->
|
||||||
entries = finalize_entry(entries, current_entry)
|
entries = finalize_entry(entries, current_entry)
|
||||||
{entries, start_entry(message, turn_index, assigns), turn_index}
|
{entries, start_entry(message, turn_index, assigns), turn_index}
|
||||||
@@ -99,6 +109,22 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
end
|
end
|
||||||
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, nil), do: nil
|
||||||
|
|
||||||
defp pending_user_message(messages, %{message: message}) when is_binary(message) do
|
defp pending_user_message(messages, %{message: message}) when is_binary(message) do
|
||||||
|
|||||||
@@ -1,54 +1,56 @@
|
|||||||
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
|
<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-header">
|
||||||
<div class="chat-panel-title">
|
<div class="chat-panel-title">
|
||||||
<%= if @chat_editor.needs_api_key? do %>
|
<span class="chat-panel-title-main">
|
||||||
<%= translated("chat.setupTitle") %>
|
<%= if @chat_editor.needs_api_key? do %>
|
||||||
<% else %>
|
<%= translated("chat.setupTitle") %>
|
||||||
<%= @chat_editor.title %>
|
<% else %>
|
||||||
|
<%= @chat_editor.title %>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<%= unless @chat_editor.needs_api_key? do %>
|
||||||
|
<span class="chat-model-selector-wrap">
|
||||||
|
<button
|
||||||
|
class="chat-model-selector-button chat-model-selector-inline"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_chat_model_selector"
|
||||||
|
data-testid="chat-model-selector-button"
|
||||||
|
>
|
||||||
|
<span><%= @chat_editor.model || translated("chat.newChat") %></span>
|
||||||
|
<span class="chat-model-selector-caret">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
||||||
|
<div class="chat-model-selector-menu">
|
||||||
|
<%= for group <- @chat_editor.available_model_groups do %>
|
||||||
|
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
|
||||||
|
<%= if length(@chat_editor.available_model_groups) > 1 do %>
|
||||||
|
<div class="chat-model-provider-header"><%= group.label %></div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= for model <- group.models do %>
|
||||||
|
<button
|
||||||
|
class={[
|
||||||
|
"chat-model-selector-option",
|
||||||
|
if(model.id == @chat_editor.model, do: "active")
|
||||||
|
]}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_chat_model"
|
||||||
|
phx-value-model={model.id}
|
||||||
|
data-testid="chat-model-selector-option"
|
||||||
|
data-provider={group.provider}
|
||||||
|
>
|
||||||
|
<span class="chat-model-selector-option-name"><%= model.name || model.id %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= unless @chat_editor.needs_api_key? do %>
|
|
||||||
<div class="chat-panel-header-actions">
|
|
||||||
<button
|
|
||||||
class="chat-model-selector-button"
|
|
||||||
type="button"
|
|
||||||
phx-click="toggle_chat_model_selector"
|
|
||||||
data-testid="chat-model-selector-button"
|
|
||||||
>
|
|
||||||
<span><%= @chat_editor.model || translated("chat.newChat") %></span>
|
|
||||||
<span class="chat-model-selector-caret">▾</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
|
||||||
<div class="chat-model-selector-menu">
|
|
||||||
<%= for group <- @chat_editor.available_model_groups do %>
|
|
||||||
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
|
|
||||||
<%= if length(@chat_editor.available_model_groups) > 1 do %>
|
|
||||||
<div class="chat-model-provider-header"><%= group.label %></div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= for model <- group.models do %>
|
|
||||||
<button
|
|
||||||
class={[
|
|
||||||
"chat-model-selector-option",
|
|
||||||
if(model.id == @chat_editor.model, do: "active")
|
|
||||||
]}
|
|
||||||
type="button"
|
|
||||||
phx-click="select_chat_model"
|
|
||||||
phx-value-model={model.id}
|
|
||||||
data-testid="chat-model-selector-option"
|
|
||||||
data-provider={group.provider}
|
|
||||||
>
|
|
||||||
<span class="chat-model-selector-option-name"><%= model.name || model.id %></span>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-messages chat-surface-scroll">
|
<div class="chat-messages chat-surface-scroll">
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
<div class="chat-message-header">
|
<div class="chat-message-header">
|
||||||
<span class="chat-message-role"><%= message_role_label(:user) %></span>
|
<span class="chat-message-role"><%= message_role_label(:user) %></span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -95,13 +97,11 @@
|
|||||||
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
|
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
|
||||||
<.chat_tool_markers markers={message.tool_markers} />
|
<.chat_tool_markers markers={message.tool_markers} />
|
||||||
|
|
||||||
<div class="chat-message-text">
|
<%= if message.role == :assistant do %>
|
||||||
<%= if message.role == :assistant do %>
|
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
|
||||||
<%= markdown_html(message.content || "") %>
|
<% else %>
|
||||||
<% else %>
|
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
|
||||||
<%= message.content || "" %>
|
<% end %>
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
"online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""),
|
"online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""),
|
||||||
"online_chat_model" =>
|
"online_chat_model" =>
|
||||||
get_model_preference(:chat) || Map.get(online_endpoint || %{}, :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_title_model" => get_model_preference(:title),
|
||||||
"online_image_analysis_model" => get_model_preference(:image_analysis),
|
"online_image_analysis_model" => get_model_preference(:image_analysis),
|
||||||
"offline_url" => Map.get(airplane_endpoint || %{}, :url, ""),
|
"offline_url" => Map.get(airplane_endpoint || %{}, :url, ""),
|
||||||
@@ -25,6 +29,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
|||||||
"offline_chat_model" =>
|
"offline_chat_model" =>
|
||||||
get_model_preference(:airplane_chat) ||
|
get_model_preference(:airplane_chat) ||
|
||||||
Map.get(airplane_endpoint || %{}, :model, ""),
|
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_title_model" => get_model_preference(:airplane_title),
|
||||||
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis),
|
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis),
|
||||||
"system_prompt" => EditorSettings.get_global_setting("ai.system_prompt") || ""
|
"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.delete_endpoint(:mistral),
|
||||||
:ok <- AI.set_airplane_mode(attrs.offline_mode),
|
:ok <- AI.set_airplane_mode(attrs.offline_mode),
|
||||||
:ok <- maybe_put_model_preference(:chat, attrs.online_chat_model),
|
: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(:title, attrs.online_title_model),
|
||||||
:ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_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_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(:airplane_title, attrs.offline_title_model),
|
||||||
:ok <-
|
:ok <-
|
||||||
maybe_put_model_preference(
|
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_url: blank_to_nil(Map.get(draft, "online_url")),
|
||||||
online_api_key: blank_to_nil(Map.get(draft, "online_api_key")),
|
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_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_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")),
|
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_url: blank_to_nil(Map.get(draft, "offline_url")),
|
||||||
offline_api_key: blank_to_nil(Map.get(draft, "offline_api_key")),
|
offline_api_key: blank_to_nil(Map.get(draft, "offline_api_key")),
|
||||||
offline_mode: truthy?(Map.get(draft, "offline_mode")),
|
offline_mode: truthy?(Map.get(draft, "offline_mode")),
|
||||||
offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")),
|
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_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")),
|
offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")),
|
||||||
system_prompt: Map.get(draft, "system_prompt", "")
|
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_url" => Map.get(params, "online_url", ""),
|
||||||
"online_api_key" => Map.get(params, "online_api_key", ""),
|
"online_api_key" => Map.get(params, "online_api_key", ""),
|
||||||
"online_chat_model" => Map.get(params, "online_chat_model", ""),
|
"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_title_model" => Map.get(params, "online_title_model", ""),
|
||||||
"online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""),
|
"online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""),
|
||||||
"offline_url" => Map.get(params, "offline_url", ""),
|
"offline_url" => Map.get(params, "offline_url", ""),
|
||||||
"offline_api_key" => Map.get(params, "offline_api_key", ""),
|
"offline_api_key" => Map.get(params, "offline_api_key", ""),
|
||||||
"offline_mode" => truthy?(Map.get(params, "offline_mode")),
|
"offline_mode" => truthy?(Map.get(params, "offline_mode")),
|
||||||
"offline_chat_model" => Map.get(params, "offline_chat_model", ""),
|
"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_title_model" => Map.get(params, "offline_title_model", ""),
|
||||||
"offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""),
|
"offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""),
|
||||||
"system_prompt" => Map.get(params, "system_prompt", "")
|
"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, ""), do: :ok
|
||||||
defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value)
|
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
|
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
|
if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do
|
||||||
AI.delete_endpoint(kind)
|
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-view">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h2 data-testid="editor-title"><%= translated("Settings") %></h2>
|
<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-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 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>
|
||||||
|
<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-row">
|
||||||
<div class="setting-info"><label class="setting-label"><%= translated("Online Title Model") %></label></div>
|
<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>
|
<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-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 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>
|
||||||
|
<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-row">
|
||||||
<div class="setting-info"><label class="setting-label"><%= translated("Offline Title Model") %></label></div>
|
<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>
|
<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
|
@moduledoc false
|
||||||
|
|
||||||
alias BDS.Desktop.{FilePicker, ShellData}
|
alias BDS.Desktop.{FilePicker, ShellData}
|
||||||
|
alias BDS.AI
|
||||||
alias BDS.ImportDefinitions
|
alias BDS.ImportDefinitions
|
||||||
alias BDS.Scripts
|
alias BDS.Scripts
|
||||||
alias BDS.Templates
|
alias BDS.Templates
|
||||||
@@ -132,6 +133,27 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
|
|||||||
end
|
end
|
||||||
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
|
def create(socket, project_id, "import", callbacks) do
|
||||||
case ImportDefinitions.create_definition(%{
|
case ImportDefinitions.create_definition(%{
|
||||||
project_id: project_id,
|
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(:media), do: %{kind: "media", label: "sidebar.importMedia"}
|
||||||
def action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"}
|
def action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"}
|
||||||
def action(:templates), do: %{kind: "template", label: "sidebar.templates.newTemplate"}
|
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(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"}
|
||||||
def action(_view), do: nil
|
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
|
@proposal_ttl_app_ms 30 * 60 * 1000
|
||||||
|
|
||||||
@typedoc "Tool descriptor returned by `list/0`."
|
@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()]
|
@spec list() :: [descriptor()]
|
||||||
def list do
|
def list do
|
||||||
@@ -75,12 +81,269 @@ defmodule BDS.MCP.Tools do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp tool(name, read_only) do
|
defp tool(name, read_only) do
|
||||||
|
metadata = tool_metadata(name)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
name: 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
|
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: check_term(%{term: term})
|
||||||
|
|
||||||
defp check_term(%{term: term}) do
|
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 {
|
.chat-panel-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--vscode-foreground, inherit);
|
color: var(--vscode-foreground, inherit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel-title-main {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-panel-header {
|
.chat-panel-header {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
@@ -5133,14 +5141,22 @@ button svg * {
|
|||||||
|
|
||||||
.chat-panel-title {
|
.chat-panel-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--vscode-foreground, inherit);
|
color: var(--vscode-foreground, inherit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel-title-main {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-panel-header-actions {
|
.chat-panel-header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -5160,9 +5176,29 @@ button svg * {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: min(40vw, 240px);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--vscode-descriptionForeground, inherit);
|
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,
|
.chat-model-selector-button:hover,
|
||||||
@@ -5177,7 +5213,7 @@ button svg * {
|
|||||||
.chat-model-selector-menu {
|
.chat-model-selector-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
right: 16px;
|
left: 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -5318,6 +5354,13 @@ button svg * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-message.user .chat-message-content {
|
.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;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5368,6 +5411,9 @@ button svg * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-message.user .chat-message-text {
|
.chat-message.user .chat-message-text {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
display: inline-block;
|
||||||
border-radius: 12px 12px 2px 12px;
|
border-radius: 12px 12px 2px 12px;
|
||||||
background-color: var(--vscode-button-background, var(--accent-color));
|
background-color: var(--vscode-button-background, var(--accent-color));
|
||||||
color: var(--vscode-button-foreground, #ffffff);
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
@@ -5377,6 +5423,74 @@ button svg * {
|
|||||||
white-space: normal;
|
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 {
|
.chat-message.streaming .chat-message-text {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
@@ -5682,11 +5796,15 @@ button svg * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container {
|
.chat-input-container {
|
||||||
padding: 16px;
|
padding: 8px 16px;
|
||||||
border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
|
border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
|
||||||
background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e));
|
background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-container {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-abort-button {
|
.chat-abort-button {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -5704,36 +5822,46 @@ button svg * {
|
|||||||
background-color: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12));
|
background-color: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper {
|
.chat-panel .chat-input-wrapper {
|
||||||
|
--chat-input-line-height: 20px;
|
||||||
|
--chat-input-min-height: 20px;
|
||||||
|
--chat-input-max-height: 160px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 8px;
|
min-height: 30px;
|
||||||
|
padding: 4px 6px;
|
||||||
border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c));
|
border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: var(--vscode-input-background, var(--panel-2, #252526));
|
background-color: var(--vscode-input-background, var(--panel-2, #252526));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper:focus-within {
|
.chat-panel .chat-input-wrapper:focus-within {
|
||||||
border-color: var(--vscode-focusBorder, var(--accent-color));
|
border-color: var(--vscode-focusBorder, var(--accent-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.chat-panel .chat-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 24px;
|
display: block;
|
||||||
max-height: 200px;
|
box-sizing: border-box;
|
||||||
|
height: var(--chat-input-min-height);
|
||||||
|
min-height: var(--chat-input-min-height);
|
||||||
|
max-height: var(--chat-input-max-height);
|
||||||
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--vscode-input-foreground, inherit);
|
color: var(--vscode-input-foreground, inherit);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
line-height: 1.5;
|
line-height: var(--chat-input-line-height);
|
||||||
resize: none;
|
resize: none;
|
||||||
overflow-y: auto;
|
overflow-y: hidden;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input::placeholder {
|
.chat-panel .chat-input::placeholder {
|
||||||
color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45));
|
color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5747,10 +5875,16 @@ button svg * {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-send-button {
|
.chat-panel .chat-send-button {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 32px;
|
box-sizing: border-box;
|
||||||
height: 32px;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
max-width: 22px;
|
||||||
|
max-height: 22px;
|
||||||
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -5758,16 +5892,17 @@ button svg * {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--vscode-button-background, var(--accent-color));
|
background-color: var(--vscode-button-background, var(--accent-color));
|
||||||
color: var(--vscode-button-foreground, #ffffff);
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-send-button:hover:not(:disabled) {
|
.chat-panel .chat-send-button:hover:not(:disabled) {
|
||||||
background-color: var(--vscode-button-hoverBackground, var(--accent-color));
|
background-color: var(--vscode-button-hoverBackground, var(--accent-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-send-button:disabled,
|
.chat-panel .chat-send-button:disabled,
|
||||||
.api-key-submit:disabled {
|
.api-key-submit:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -5945,6 +6080,10 @@ button svg * {
|
|||||||
.chat-input-container {
|
.chat-input-container {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-container {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -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: {
|
ChatSurface: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.stickToBottom = true;
|
this.stickToBottom = true;
|
||||||
@@ -702,8 +731,25 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea.style.height = "auto";
|
const styles = getComputedStyle(textarea);
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20;
|
||||||
|
const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160;
|
||||||
|
|
||||||
|
textarea.rows = 1;
|
||||||
|
textarea.style.minHeight = `${minHeight}px`;
|
||||||
|
|
||||||
|
if (textarea.value.trim() === "") {
|
||||||
|
textarea.style.height = `${minHeight}px`;
|
||||||
|
textarea.style.maxHeight = `${minHeight}px`;
|
||||||
|
textarea.style.overflowY = "hidden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.style.maxHeight = `${maxHeight}px`;
|
||||||
|
textarea.style.height = "0px";
|
||||||
|
const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
|
||||||
|
textarea.style.height = `${nextHeight}px`;
|
||||||
|
textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden";
|
||||||
};
|
};
|
||||||
|
|
||||||
this.syncScrollContainer = () => {
|
this.syncScrollContainer = () => {
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ defmodule BDS.AITest do
|
|||||||
url: "https://api.example.test/v1",
|
url: "https://api.example.test/v1",
|
||||||
api_key: "top-secret",
|
api_key: "top-secret",
|
||||||
model: "gpt-4o-mini"
|
model: "gpt-4o-mini"
|
||||||
}, secret_backend: FakeSecretBackend)
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert endpoint.kind == :online
|
assert endpoint.kind == :online
|
||||||
assert endpoint.url == "https://api.example.test/v1"
|
assert endpoint.url == "https://api.example.test/v1"
|
||||||
@@ -316,7 +318,9 @@ defmodule BDS.AITest do
|
|||||||
url: "https://api.example.test/v1",
|
url: "https://api.example.test/v1",
|
||||||
api_key: "online-secret",
|
api_key: "online-secret",
|
||||||
model: "gpt-4o-mini"
|
model: "gpt-4o-mini"
|
||||||
}, secret_backend: FakeSecretBackend)
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert {:ok, _endpoint} =
|
assert {:ok, _endpoint} =
|
||||||
BDS.AI.put_endpoint(
|
BDS.AI.put_endpoint(
|
||||||
@@ -325,7 +329,9 @@ defmodule BDS.AITest do
|
|||||||
url: "http://localhost:11434/v1",
|
url: "http://localhost:11434/v1",
|
||||||
api_key: nil,
|
api_key: nil,
|
||||||
model: "llama-default"
|
model: "llama-default"
|
||||||
}, secret_backend: FakeSecretBackend)
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert :ok = BDS.AI.set_airplane_mode(true)
|
assert :ok = BDS.AI.set_airplane_mode(true)
|
||||||
assert :ok = BDS.AI.put_model_preference(:airplane_title, "llama3.1")
|
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",
|
url: "https://api.example.test/v1",
|
||||||
api_key: "online-secret",
|
api_key: "online-secret",
|
||||||
model: "gpt-4o-mini"
|
model: "gpt-4o-mini"
|
||||||
}, secret_backend: FakeSecretBackend)
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert :ok = BDS.AI.set_airplane_mode(false)
|
assert :ok = BDS.AI.set_airplane_mode(false)
|
||||||
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
|
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",
|
url: "https://api.example.test/v1",
|
||||||
api_key: "online-secret",
|
api_key: "online-secret",
|
||||||
model: "gpt-4o-mini"
|
model: "gpt-4o-mini"
|
||||||
}, secret_backend: FakeSecretBackend)
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert :ok = BDS.AI.set_airplane_mode(false)
|
assert :ok = BDS.AI.set_airplane_mode(false)
|
||||||
assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini")
|
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",
|
url: "http://localhost:11434/v1",
|
||||||
api_key: nil,
|
api_key: nil,
|
||||||
model: "llama-default"
|
model: "llama-default"
|
||||||
}, secret_backend: FakeSecretBackend)
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert :ok = BDS.AI.set_airplane_mode(true)
|
assert :ok = BDS.AI.set_airplane_mode(true)
|
||||||
assert :ok = BDS.AI.put_model_preference(:airplane_image_analysis, "llama3.2")
|
assert :ok = BDS.AI.put_model_preference(:airplane_image_analysis, "llama3.2")
|
||||||
@@ -434,7 +446,11 @@ defmodule BDS.AITest do
|
|||||||
alt: nil,
|
alt: nil,
|
||||||
caption: nil,
|
caption: nil,
|
||||||
image_url: "file:///tmp/test.png"
|
image_url: "file:///tmp/test.png"
|
||||||
}, runtime: FakeRuntime, test_pid: self(), secret_backend: FakeSecretBackend)
|
},
|
||||||
|
runtime: FakeRuntime,
|
||||||
|
test_pid: self(),
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert :ok =
|
assert :ok =
|
||||||
BDS.AI.put_model_capabilities("llama3.2", %{
|
BDS.AI.put_model_capabilities("llama3.2", %{
|
||||||
@@ -450,7 +466,11 @@ defmodule BDS.AITest do
|
|||||||
alt: nil,
|
alt: nil,
|
||||||
caption: nil,
|
caption: nil,
|
||||||
image_url: "file:///tmp/test.png"
|
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"
|
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
|
test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do
|
||||||
{:ok, project} = create_project_fixture("AI Chat")
|
{:ok, project} = create_project_fixture("AI Chat")
|
||||||
:ok = seed_project_content(project.id)
|
_fixtures = seed_project_content(project.id)
|
||||||
|
|
||||||
assert {:ok, _endpoint} =
|
assert {:ok, _endpoint} =
|
||||||
BDS.AI.put_endpoint(
|
BDS.AI.put_endpoint(
|
||||||
@@ -471,7 +491,9 @@ defmodule BDS.AITest do
|
|||||||
url: "https://api.example.test/v1",
|
url: "https://api.example.test/v1",
|
||||||
api_key: "online-secret",
|
api_key: "online-secret",
|
||||||
model: "gpt-4o-mini"
|
model: "gpt-4o-mini"
|
||||||
}, secret_backend: FakeSecretBackend)
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert :ok = BDS.AI.set_airplane_mode(false)
|
assert :ok = BDS.AI.set_airplane_mode(false)
|
||||||
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
|
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 ->
|
assert Enum.any?(first_request.messages, fn message ->
|
||||||
message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and
|
message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and
|
||||||
String.contains?(message["content"], "Media: 1")
|
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)
|
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"] == "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
|
end
|
||||||
|
|
||||||
test "cancel_chat aborts an in-flight chat turn" do
|
test "cancel_chat aborts an in-flight chat turn" do
|
||||||
@@ -520,7 +735,9 @@ defmodule BDS.AITest do
|
|||||||
url: "https://api.example.test/v1",
|
url: "https://api.example.test/v1",
|
||||||
api_key: "online-secret",
|
api_key: "online-secret",
|
||||||
model: "gpt-4o-mini"
|
model: "gpt-4o-mini"
|
||||||
}, secret_backend: FakeSecretBackend)
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
|
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
|
||||||
|
|
||||||
@@ -558,36 +775,39 @@ defmodule BDS.AITest do
|
|||||||
defp seed_project_content(project_id) do
|
defp seed_project_content(project_id) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
Repo.insert!(
|
post =
|
||||||
Post.changeset(%Post{}, %{
|
Repo.insert!(
|
||||||
id: Ecto.UUID.generate(),
|
Post.changeset(%Post{}, %{
|
||||||
project_id: project_id,
|
id: Ecto.UUID.generate(),
|
||||||
title: "AI Post",
|
project_id: project_id,
|
||||||
slug: "ai-post",
|
title: "AI Post",
|
||||||
excerpt: "Summary",
|
slug: "ai-post",
|
||||||
content: "Body",
|
excerpt: "Summary",
|
||||||
status: :draft,
|
content: "Body",
|
||||||
created_at: now,
|
status: :draft,
|
||||||
updated_at: now,
|
created_at: now,
|
||||||
do_not_translate: false
|
updated_at: now,
|
||||||
})
|
do_not_translate: false
|
||||||
)
|
})
|
||||||
|
)
|
||||||
|
|
||||||
Repo.insert!(
|
media =
|
||||||
Media.changeset(%Media{}, %{
|
Repo.insert!(
|
||||||
id: Ecto.UUID.generate(),
|
Media.changeset(%Media{}, %{
|
||||||
project_id: project_id,
|
id: Ecto.UUID.generate(),
|
||||||
filename: "image.png",
|
project_id: project_id,
|
||||||
original_name: "image.png",
|
filename: "image.png",
|
||||||
mime_type: "image/png",
|
original_name: "image.png",
|
||||||
size: 128,
|
mime_type: "image/png",
|
||||||
file_path: "media/image.png",
|
size: 128,
|
||||||
sidecar_path: "media/image.png.meta",
|
title: "Hero",
|
||||||
created_at: now,
|
file_path: "media/image.png",
|
||||||
updated_at: now
|
sidecar_path: "media/image.png.meta",
|
||||||
})
|
created_at: now,
|
||||||
)
|
updated_at: now
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
:ok
|
%{post: post, media: media}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ defmodule BDS.Desktop.MainWindowTest do
|
|||||||
assert MainWindow.restore_bounds() == %{x: 120, y: 80, width: 1260, height: 820}
|
assert MainWindow.restore_bounds() == %{x: 120, y: 80, width: 1260, height: 820}
|
||||||
end
|
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
|
test "window options clamp oversized startup bounds to the visible client area" do
|
||||||
desktop = Application.get_env(:bds, :desktop, [])
|
desktop = Application.get_env(:bds, :desktop, [])
|
||||||
|
|
||||||
@@ -63,4 +68,37 @@ defmodule BDS.Desktop.MainWindowTest do
|
|||||||
|
|
||||||
assert opts[:size] == {1200, 700}
|
assert opts[:size] == {1200, 700}
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
%{project: project, temp_dir: temp_dir}
|
%{project: project, temp_dir: temp_dir}
|
||||||
end
|
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)
|
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
assert html =~ ~s(data-testid="sidebar-create-action")
|
assert html =~ ~s(data-testid="sidebar-create-action")
|
||||||
@@ -162,6 +162,13 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
|
|
||||||
assert html =~ ~s(data-sidebar-action="template")
|
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 =
|
html =
|
||||||
view
|
view
|
||||||
|> element("[data-testid='activity-button'][data-view='import']")
|
|> element("[data-testid='activity-button'][data-view='import']")
|
||||||
@@ -170,13 +177,15 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(data-sidebar-action="import")
|
assert html =~ ~s(data-sidebar-action="import")
|
||||||
end
|
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
|
project: project
|
||||||
|
} do
|
||||||
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
post_count_before = Repo.aggregate(Post, :count, :id)
|
post_count_before = Repo.aggregate(Post, :count, :id)
|
||||||
script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id)
|
script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id)
|
||||||
template_count_before = Repo.aggregate(BDS.Templates.Template, :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)
|
import_count_before = Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id)
|
||||||
|
|
||||||
html =
|
html =
|
||||||
@@ -225,6 +234,20 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(data-tab-type="templates")
|
assert html =~ ~s(data-tab-type="templates")
|
||||||
assert html =~ ~s(data-tab-id="#{created_template.id}")
|
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 = render_click(view, "select_view", %{"view" => "import"})
|
||||||
|
|
||||||
html =
|
html =
|
||||||
@@ -242,6 +265,21 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(data-tab-id="#{created_definition.id}")
|
assert html =~ ~s(data-tab-id="#{created_definition.id}")
|
||||||
end
|
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",
|
test "shell live refreshes the posts sidebar when the CLI watcher broadcasts an entity change",
|
||||||
%{project: project} do
|
%{project: project} do
|
||||||
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
{: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_url" => "https://api.example.test/v1",
|
||||||
"online_api_key" => "online-secret",
|
"online_api_key" => "online-secret",
|
||||||
"online_chat_model" => "gpt-4.1",
|
"online_chat_model" => "gpt-4.1",
|
||||||
|
"online_chat_tools" => "true",
|
||||||
"online_title_model" => "gpt-4.1-mini",
|
"online_title_model" => "gpt-4.1-mini",
|
||||||
"online_image_analysis_model" => "gpt-4.1-vision",
|
"online_image_analysis_model" => "gpt-4.1-vision",
|
||||||
"offline_url" => "http://localhost:11434/v1",
|
"offline_url" => "http://localhost:11434/v1",
|
||||||
"offline_api_key" => "",
|
"offline_api_key" => "",
|
||||||
"offline_chat_model" => "llama3.3",
|
"offline_chat_model" => "llama3.3",
|
||||||
|
"offline_chat_tools" => "true",
|
||||||
"offline_title_model" => "llama3.2",
|
"offline_title_model" => "llama3.2",
|
||||||
"offline_image_analysis_model" => "llava:latest",
|
"offline_image_analysis_model" => "llava:latest",
|
||||||
"offline_mode" => "true",
|
"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.3"} = AI.get_model_preference(:airplane_chat)
|
||||||
assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title)
|
assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title)
|
||||||
assert {:ok, "llava:latest"} = AI.get_model_preference(:airplane_image_analysis)
|
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
|
end
|
||||||
|
|
||||||
test "ai settings refresh models from the configured endpoints" do
|
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."
|
refute chat_html =~ "Desktop workbench content routed through the Elixir shell."
|
||||||
end
|
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
|
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"})
|
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"
|
assert html =~ "Posts"
|
||||||
end
|
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
|
test "chat editor groups selector models by provider and uses catalog labels" do
|
||||||
updated_at = Persistence.now_ms()
|
updated_at = Persistence.now_ms()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ defmodule BDS.DesktopTest do
|
|||||||
|
|
||||||
import Plug.Test
|
import Plug.Test
|
||||||
|
|
||||||
|
defmodule FakeShutdown do
|
||||||
|
def request_quit do
|
||||||
|
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :quit_requested)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
test "desktop configuration no longer uses a pending adapter" do
|
||||||
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
|
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
|
||||||
end
|
end
|
||||||
@@ -99,6 +113,61 @@ defmodule BDS.DesktopTest do
|
|||||||
assert menu_item(groups, :metadata_diff).shortcut == nil
|
assert menu_item(groups, :metadata_diff).shortcut == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "native menu quit requests app-owned shutdown" do
|
||||||
|
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
|
||||||
|
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)
|
||||||
|
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_module, FakeShutdown)
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_test_pid, self())
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
restore_env(:desktop_shutdown_module, previous_module)
|
||||||
|
restore_env(:desktop_shutdown_test_pid, previous_pid)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:noreply, %{}} = BDS.Desktop.MenuBar.handle_event("quit", %{})
|
||||||
|
assert_receive :quit_requested
|
||||||
|
end
|
||||||
|
|
||||||
|
test "icon menu quit requests app-owned shutdown" do
|
||||||
|
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
|
||||||
|
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)
|
||||||
|
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_module, FakeShutdown)
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_test_pid, self())
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
restore_env(:desktop_shutdown_module, previous_module)
|
||||||
|
restore_env(:desktop_shutdown_test_pid, previous_pid)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:noreply, %{}} = BDS.Desktop.Menu.handle_event("quit", %{})
|
||||||
|
assert_receive :quit_requested
|
||||||
|
end
|
||||||
|
|
||||||
|
test "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
|
test "desktop root html is a LiveView shell and references only the live bootstrap assets" do
|
||||||
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
|
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
|
||||||
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
|
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
|
||||||
@@ -178,4 +247,7 @@ defmodule BDS.DesktopTest do
|
|||||||
Image.new!(3, 2, color: [255, 0, 0])
|
Image.new!(3, 2, color: [255, 0, 0])
|
||||||
|> Image.write!(:memory, suffix: ".jpg", quality: 85)
|
|> Image.write!(:memory, suffix: ".jpg", quality: 85)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp restore_env(key, nil), do: Application.delete_env(:bds, key)
|
||||||
|
defp restore_env(key, value), do: Application.put_env(:bds, key, value)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ defmodule BDS.MCPTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "list_tools follows the old app tool surface for implemented backend features" do
|
test "list_tools follows the old app tool surface for implemented backend features" do
|
||||||
tool_names =
|
tools = BDS.MCP.list_tools()
|
||||||
BDS.MCP.list_tools()
|
tool_names = Enum.map(tools, & &1.name)
|
||||||
|> Enum.map(& &1.name)
|
|
||||||
|
|
||||||
assert "check_term" in tool_names
|
assert "check_term" in tool_names
|
||||||
assert "search_posts" 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 "propose_post_metadata" in tool_names
|
||||||
assert "accept_proposal" in tool_names
|
assert "accept_proposal" in tool_names
|
||||||
assert "discard_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
|
end
|
||||||
|
|
||||||
test "check_term, search_posts, count_posts, and read_post_by_slug expose current blog data", %{
|
test "check_term, search_posts, count_posts, and read_post_by_slug expose current blog data", %{
|
||||||
|
|||||||
Reference in New Issue
Block a user