fix: ai chat styling and some crashes
This commit is contained in:
@@ -527,13 +527,26 @@ defmodule BDS.AI.Chat do
|
|||||||
Enum.join(
|
Enum.join(
|
||||||
[
|
[
|
||||||
"Available blog data tools:",
|
"Available blog data tools:",
|
||||||
"- Use blog_stats for aggregate counts of posts, media, tags, and categories.",
|
"- Use get_blog_stats for aggregate counts of posts, media, tags, and categories.",
|
||||||
"- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.",
|
"- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.",
|
||||||
|
"- Use read_post to read a post by ID, or read_post_by_slug to read a post by slug.",
|
||||||
"- Use read_post_by_slug to read full post content and metadata when a slug is known.",
|
"- Use read_post_by_slug to read full post content and metadata when a slug is known.",
|
||||||
"- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.",
|
"- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.",
|
||||||
"- Use list_media when asked for media titles, filenames, MIME types, or recent media lists. This is allowed project data access.",
|
"- Use get_media for one media item by ID, list_media for media titles, filenames, MIME types, or recent media lists, and view_image for visual image inspection.",
|
||||||
|
"- Use update_post_metadata and update_media_metadata when asked to change titles, excerpts, tags, categories, alt text, or captions.",
|
||||||
|
"- Use get_post_backlinks, get_post_outlinks, get_post_media, and get_media_posts for relationship questions.",
|
||||||
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
|
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
|
||||||
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data."
|
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
|
||||||
|
"",
|
||||||
|
"Available UI Render Tools:",
|
||||||
|
"- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.",
|
||||||
|
"- Use render_table for tabular data, comparisons, and structured listings.",
|
||||||
|
"- Use render_form to collect structured user input.",
|
||||||
|
"- Use render_card for summaries, highlights, or actionable items.",
|
||||||
|
"- Use render_metric for a single KPI or important statistic.",
|
||||||
|
"- Use render_list for bullet lists, checklists, or simple enumerations.",
|
||||||
|
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.",
|
||||||
|
"When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data."
|
||||||
],
|
],
|
||||||
"\n"
|
"\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ defmodule BDS.AI.ChatTools do
|
|||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
|
alias BDS.Media, as: MediaContext
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.MCP.Queries
|
alias BDS.MCP.Queries
|
||||||
|
alias BDS.Posts, as: PostsContext
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Posts.PostMedia
|
||||||
alias BDS.Projects.Project
|
alias BDS.Projects.Project
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Search
|
alias BDS.Search
|
||||||
|
|
||||||
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
||||||
def execute("blog_stats", _arguments, project_id) do
|
def execute("blog_stats", _arguments, project_id) do
|
||||||
|
execute("get_blog_stats", %{}, project_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_blog_stats", _arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
%{
|
%{
|
||||||
@@ -73,6 +80,18 @@ defmodule BDS.AI.ChatTools do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def execute("read_post", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Post,
|
||||||
|
id: arguments["postId"] || arguments["post_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Post{} = post -> %{post: Queries.post_detail(post)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def execute("list_posts", arguments, project_id) do
|
def execute("list_posts", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
limit = normalize_limit(arguments["limit"])
|
limit = normalize_limit(arguments["limit"])
|
||||||
@@ -116,6 +135,70 @@ defmodule BDS.AI.ChatTools do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def execute("get_media", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Media,
|
||||||
|
id: arguments["mediaId"] || arguments["media_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Media{} = media -> %{media: media_summary(media)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("view_image", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
media_id = arguments["mediaId"] || arguments["media_id"]
|
||||||
|
size = arguments["size"] || "medium"
|
||||||
|
|
||||||
|
case Repo.get_by(Media, id: media_id, project_id: project_id) do
|
||||||
|
%Media{mime_type: "image/" <> _rest} = media ->
|
||||||
|
case thumbnail_data_url(project_id, media, size) do
|
||||||
|
nil -> %{success: false, error: "thumbnail_not_available"}
|
||||||
|
data_url -> %{success: true, media: media_summary(media), data_url: data_url}
|
||||||
|
end
|
||||||
|
|
||||||
|
%Media{} = media ->
|
||||||
|
%{success: false, error: "not_image", mime_type: media.mime_type}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
%{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("update_post_metadata", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
post_id = arguments["postId"] || arguments["post_id"]
|
||||||
|
|
||||||
|
with %Post{} <- Repo.get_by(Post, id: post_id, project_id: project_id),
|
||||||
|
attrs <- metadata_attrs(arguments, ["title", "excerpt", "tags", "categories"]),
|
||||||
|
false <- attrs == %{},
|
||||||
|
{:ok, post} <- PostsContext.update_post(post_id, attrs) do
|
||||||
|
%{success: true, post: Queries.post_detail(post)}
|
||||||
|
else
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
true -> %{success: false, error: "no_updates_provided"}
|
||||||
|
{:error, reason} -> %{success: false, error: inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("update_media_metadata", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
media_id = arguments["mediaId"] || arguments["media_id"]
|
||||||
|
|
||||||
|
with %Media{} <- Repo.get_by(Media, id: media_id, project_id: project_id),
|
||||||
|
attrs <- metadata_attrs(arguments, ["title", "alt", "caption", "tags"]),
|
||||||
|
false <- attrs == %{},
|
||||||
|
{:ok, media} <- MediaContext.update_media(media_id, attrs) do
|
||||||
|
%{success: true, media: media_summary(media)}
|
||||||
|
else
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
true -> %{success: false, error: "no_updates_provided"}
|
||||||
|
{:error, reason} -> %{success: false, error: inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def execute("list_tags", _arguments, project_id) do
|
def execute("list_tags", _arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
@@ -149,6 +232,56 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{groups: groups, total_posts: result.total}
|
%{groups: groups, total_posts: result.total}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def execute("get_post_backlinks", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Post,
|
||||||
|
id: arguments["postId"] || arguments["post_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Post{} = post ->
|
||||||
|
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
%{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_post_outlinks", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Post,
|
||||||
|
id: arguments["postId"] || arguments["post_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Post{} = post ->
|
||||||
|
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
%{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_post_media", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
post_id = arguments["postId"] || arguments["post_id"]
|
||||||
|
|
||||||
|
case Repo.get_by(Post, id: post_id, project_id: project_id) do
|
||||||
|
%Post{} = post -> %{success: true, post_id: post.id, media: post_media(project_id, post.id)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_media_posts", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
media_id = arguments["mediaId"] || arguments["media_id"]
|
||||||
|
|
||||||
|
case Repo.get_by(Media, id: media_id, project_id: project_id) do
|
||||||
|
%Media{} = media -> %{success: true, media_id: media.id, posts: media_posts(media.id)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def execute("render_table", arguments, _project_id) do
|
def execute("render_table", arguments, _project_id) do
|
||||||
%{
|
%{
|
||||||
type: "table",
|
type: "table",
|
||||||
@@ -162,7 +295,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
type: "chart",
|
type: "chart",
|
||||||
title: arguments["title"],
|
title: arguments["title"],
|
||||||
chart_type: arguments["chart_type"] || "bar",
|
chart_type: arguments["chartType"] || arguments["chart_type"] || "bar",
|
||||||
series: arguments["series"] || []
|
series: arguments["series"] || []
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -237,6 +370,15 @@ defmodule BDS.AI.ChatTools do
|
|||||||
"properties" => %{}
|
"properties" => %{}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
%{
|
||||||
|
name: "get_blog_stats",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_blog_stats",
|
||||||
|
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
|
||||||
|
%{"type" => "object", "properties" => %{}}
|
||||||
|
)
|
||||||
|
},
|
||||||
%{
|
%{
|
||||||
name: "check_term",
|
name: "check_term",
|
||||||
spec:
|
spec:
|
||||||
@@ -259,6 +401,19 @@ defmodule BDS.AI.ChatTools do
|
|||||||
post_search_schema(true)
|
post_search_schema(true)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
%{
|
||||||
|
name: "read_post",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"read_post",
|
||||||
|
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"postId" => %{"type" => "string"}},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
%{
|
%{
|
||||||
name: "read_post_by_slug",
|
name: "read_post_by_slug",
|
||||||
spec:
|
spec:
|
||||||
@@ -281,6 +436,15 @@ defmodule BDS.AI.ChatTools do
|
|||||||
post_search_schema(false)
|
post_search_schema(false)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
%{
|
||||||
|
name: "get_media",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_media",
|
||||||
|
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
|
||||||
|
media_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
%{
|
%{
|
||||||
name: "list_media",
|
name: "list_media",
|
||||||
spec:
|
spec:
|
||||||
@@ -290,6 +454,35 @@ defmodule BDS.AI.ChatTools do
|
|||||||
limit_schema()
|
limit_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
%{
|
||||||
|
name: "view_image",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"view_image",
|
||||||
|
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
|
||||||
|
media_id_schema(%{
|
||||||
|
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "update_post_metadata",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"update_post_metadata",
|
||||||
|
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
|
||||||
|
update_post_metadata_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "update_media_metadata",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"update_media_metadata",
|
||||||
|
"Update metadata for a media file: title, alt text, caption, or tags.",
|
||||||
|
update_media_metadata_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
%{
|
%{
|
||||||
name: "list_tags",
|
name: "list_tags",
|
||||||
spec:
|
spec:
|
||||||
@@ -319,6 +512,42 @@ defmodule BDS.AI.ChatTools do
|
|||||||
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
|
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
|
||||||
count_posts_schema()
|
count_posts_schema()
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_backlinks",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_post_backlinks",
|
||||||
|
"Get all posts that link to a specific post.",
|
||||||
|
post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_outlinks",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_post_outlinks",
|
||||||
|
"Get all posts that a specific post links to.",
|
||||||
|
post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_media",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_post_media",
|
||||||
|
"Get media files linked to a specific post.",
|
||||||
|
post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_media_posts",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_media_posts",
|
||||||
|
"Get posts that use a specific media file.",
|
||||||
|
media_id_schema()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
else
|
else
|
||||||
@@ -330,14 +559,18 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "render_card",
|
name: "render_card",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("render_card", "Return a structured card payload", render_card_schema())
|
tool_spec(
|
||||||
|
"render_card",
|
||||||
|
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
|
||||||
|
render_card_schema()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_table",
|
name: "render_table",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"render_table",
|
"render_table",
|
||||||
"Return a structured table payload",
|
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
|
||||||
render_table_schema()
|
render_table_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -346,40 +579,52 @@ defmodule BDS.AI.ChatTools do
|
|||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"render_chart",
|
"render_chart",
|
||||||
"Return a structured chart payload",
|
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
|
||||||
render_chart_schema()
|
render_chart_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_form",
|
name: "render_form",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("render_form", "Return a structured form payload", render_form_schema())
|
tool_spec(
|
||||||
|
"render_form",
|
||||||
|
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
|
||||||
|
render_form_schema()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_metric",
|
name: "render_metric",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"render_metric",
|
"render_metric",
|
||||||
"Return a structured metric payload",
|
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
|
||||||
render_metric_schema()
|
render_metric_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_list",
|
name: "render_list",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("render_list", "Return a structured list payload", render_list_schema())
|
tool_spec(
|
||||||
|
"render_list",
|
||||||
|
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
|
||||||
|
render_list_schema()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_tabs",
|
name: "render_tabs",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())
|
tool_spec(
|
||||||
|
"render_tabs",
|
||||||
|
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
|
||||||
|
render_tabs_schema()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_mindmap",
|
name: "render_mindmap",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
tool_spec(
|
||||||
"render_mindmap",
|
"render_mindmap",
|
||||||
"Return a structured mindmap payload",
|
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
|
||||||
render_mindmap_schema()
|
render_mindmap_schema()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -450,13 +695,65 @@ defmodule BDS.AI.ChatTools do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp post_id_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"postId" => %{"type" => "string"}},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_id_schema(extra_properties \\ %{}) do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
|
||||||
|
"required" => ["mediaId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_post_metadata_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"postId" => %{"type" => "string"},
|
||||||
|
"title" => %{"type" => "string"},
|
||||||
|
"excerpt" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||||
|
},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_media_metadata_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"mediaId" => %{"type" => "string"},
|
||||||
|
"title" => %{"type" => "string"},
|
||||||
|
"alt" => %{"type" => "string"},
|
||||||
|
"caption" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||||
|
},
|
||||||
|
"required" => ["mediaId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp render_table_schema do
|
defp render_table_schema do
|
||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"title" => %{"type" => "string", "description" => "Optional table title"},
|
||||||
"columns" => %{"type" => "array"},
|
"columns" => %{
|
||||||
"rows" => %{"type" => "array"}
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "Column header names"
|
||||||
|
},
|
||||||
|
"rows" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"description" => "Table rows, each row is an array of cell values"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -465,11 +762,41 @@ 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
|
||||||
|
|
||||||
defp render_form_schema do
|
defp render_form_schema do
|
||||||
@@ -570,6 +897,79 @@ defmodule BDS.AI.ChatTools do
|
|||||||
|> Enum.sort_by(&String.downcase(to_string(&1.name)))
|
|> Enum.sort_by(&String.downcase(to_string(&1.name)))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp metadata_attrs(arguments, keys) do
|
||||||
|
Enum.reduce(keys, %{}, fn key, acc ->
|
||||||
|
maybe_put(acc, String.to_atom(key), arguments[key])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_summary(%Media{} = media) do
|
||||||
|
%{
|
||||||
|
id: media.id,
|
||||||
|
filename: media.filename,
|
||||||
|
original_name: media.original_name,
|
||||||
|
mime_type: media.mime_type,
|
||||||
|
size: media.size,
|
||||||
|
width: media.width,
|
||||||
|
height: media.height,
|
||||||
|
title: media.title,
|
||||||
|
alt: media.alt,
|
||||||
|
caption: media.caption,
|
||||||
|
author: media.author,
|
||||||
|
language: media.language,
|
||||||
|
tags: media.tags || [],
|
||||||
|
created_at: media.created_at,
|
||||||
|
updated_at: media.updated_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_media(project_id, post_id) do
|
||||||
|
Repo.all(
|
||||||
|
from media in Media,
|
||||||
|
join: post_media in PostMedia,
|
||||||
|
on: post_media.media_id == media.id,
|
||||||
|
where: post_media.project_id == ^project_id and post_media.post_id == ^post_id,
|
||||||
|
order_by: [asc: post_media.sort_order, asc: media.updated_at]
|
||||||
|
)
|
||||||
|
|> Enum.map(&media_summary/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_posts(media_id) do
|
||||||
|
MediaContext.list_linked_posts(media_id)
|
||||||
|
|> Enum.map(fn post ->
|
||||||
|
%{"id" => post.post_id, "title" => post.title, "sort_order" => post.sort_order}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp thumbnail_data_url(project_id, media, size) do
|
||||||
|
project = Repo.get!(Project, project_id)
|
||||||
|
size_key = thumbnail_size(size)
|
||||||
|
relative_path = MediaContext.thumbnail_paths(media)[size_key]
|
||||||
|
absolute_path = Path.join(project.data_path, relative_path || "")
|
||||||
|
|
||||||
|
with true <- is_binary(relative_path),
|
||||||
|
true <- File.exists?(absolute_path),
|
||||||
|
{:ok, binary} <- File.read(absolute_path) do
|
||||||
|
"data:#{thumbnail_mime(absolute_path)};base64," <> Base.encode64(binary)
|
||||||
|
else
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp thumbnail_size("small"), do: :small
|
||||||
|
defp thumbnail_size("large"), do: :large
|
||||||
|
defp thumbnail_size(_size), do: :medium
|
||||||
|
|
||||||
|
defp thumbnail_mime(path) do
|
||||||
|
case Path.extname(path) |> String.downcase() do
|
||||||
|
".jpg" -> "image/jpeg"
|
||||||
|
".jpeg" -> "image/jpeg"
|
||||||
|
".png" -> "image/png"
|
||||||
|
".webp" -> "image/webp"
|
||||||
|
_other -> "application/octet-stream"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == ""
|
defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == ""
|
||||||
|
|
||||||
defp normalize_term(value), do: value |> to_string() |> String.downcase()
|
defp normalize_term(value), do: value |> to_string() |> String.downcase()
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
|
|
||||||
frame ->
|
frame ->
|
||||||
apply_restored_bounds(frame)
|
apply_restored_bounds(frame)
|
||||||
|
BDS.Desktop.Shutdown.install_handlers(frame)
|
||||||
schedule_persist()
|
schedule_persist()
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
92
lib/bds/desktop/shutdown.ex
Normal file
92
lib/bds/desktop/shutdown.ex
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
defmodule BDS.Desktop.Shutdown do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.Desktop.MainWindow
|
||||||
|
alias Desktop.Window
|
||||||
|
|
||||||
|
@stop_delay_ms 100
|
||||||
|
|
||||||
|
@spec install_handlers(term()) :: :ok
|
||||||
|
def install_handlers(frame) do
|
||||||
|
:wx.set_env(Desktop.Env.wx_env())
|
||||||
|
|
||||||
|
_ = :wxFrame.disconnect(frame, :close_window)
|
||||||
|
|
||||||
|
:wxFrame.connect(frame, :close_window,
|
||||||
|
callback: &__MODULE__.close_window/2,
|
||||||
|
userData: self()
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = :wxFrame.disconnect(frame, :command_menu_selected, id: Desktop.Wx.wxID_EXIT())
|
||||||
|
|
||||||
|
:wxFrame.connect(frame, :command_menu_selected,
|
||||||
|
id: Desktop.Wx.wxID_EXIT(),
|
||||||
|
callback: &__MODULE__.command_menu_selected/2
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
rescue
|
||||||
|
_error -> :ok
|
||||||
|
catch
|
||||||
|
:exit, _reason -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec request_quit() :: :ok
|
||||||
|
def request_quit do
|
||||||
|
case Application.get_env(:bds, :desktop_shutdown_module, __MODULE__) do
|
||||||
|
__MODULE__ ->
|
||||||
|
start_shutdown_task()
|
||||||
|
|
||||||
|
module when is_atom(module) ->
|
||||||
|
module.request_quit()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec close_window(tuple(), term()) :: :ok
|
||||||
|
def close_window(_event, close_event) do
|
||||||
|
if :wxCloseEvent.canVeto(close_event) do
|
||||||
|
:wxCloseEvent.veto(close_event)
|
||||||
|
end
|
||||||
|
|
||||||
|
request_quit()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec command_menu_selected(tuple(), term()) :: :ok
|
||||||
|
def command_menu_selected(_event, _command_event) do
|
||||||
|
request_quit()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_shutdown_task do
|
||||||
|
Task.start(fn ->
|
||||||
|
close_main_window()
|
||||||
|
Process.sleep(@stop_delay_ms)
|
||||||
|
System.stop(0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp close_main_window do
|
||||||
|
with frame when not is_nil(frame) <- main_frame() do
|
||||||
|
:wx.set_env(Desktop.Env.wx_env())
|
||||||
|
|
||||||
|
if :wxWindow.isShown(frame) do
|
||||||
|
:wxWindow.hide(frame)
|
||||||
|
end
|
||||||
|
|
||||||
|
:wxWindow.destroy(frame)
|
||||||
|
else
|
||||||
|
_other -> :ok
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_error -> :ok
|
||||||
|
catch
|
||||||
|
:exit, _reason -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp main_frame do
|
||||||
|
Window.frame(MainWindow.window_id())
|
||||||
|
catch
|
||||||
|
:exit, _reason -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5796,11 +5796,15 @@ button svg * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container {
|
.chat-input-container {
|
||||||
padding: 16px;
|
padding: 8px 16px;
|
||||||
border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
|
border-top: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
|
||||||
background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e));
|
background-color: var(--vscode-sideBar-background, var(--panel-1, #1e1e1e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-container {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-abort-button {
|
.chat-abort-button {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -5818,36 +5822,46 @@ button svg * {
|
|||||||
background-color: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12));
|
background-color: var(--vscode-inputValidation-errorBackground, rgba(244, 135, 113, 0.12));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper {
|
.chat-panel .chat-input-wrapper {
|
||||||
|
--chat-input-line-height: 20px;
|
||||||
|
--chat-input-min-height: 20px;
|
||||||
|
--chat-input-max-height: 160px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 8px;
|
min-height: 30px;
|
||||||
|
padding: 4px 6px;
|
||||||
border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c));
|
border: 1px solid var(--vscode-input-border, var(--line, #3c3c3c));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: var(--vscode-input-background, var(--panel-2, #252526));
|
background-color: var(--vscode-input-background, var(--panel-2, #252526));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper:focus-within {
|
.chat-panel .chat-input-wrapper:focus-within {
|
||||||
border-color: var(--vscode-focusBorder, var(--accent-color));
|
border-color: var(--vscode-focusBorder, var(--accent-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input {
|
.chat-panel .chat-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 24px;
|
display: block;
|
||||||
max-height: 200px;
|
box-sizing: border-box;
|
||||||
|
height: var(--chat-input-min-height);
|
||||||
|
min-height: var(--chat-input-min-height);
|
||||||
|
max-height: var(--chat-input-max-height);
|
||||||
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--vscode-input-foreground, inherit);
|
color: var(--vscode-input-foreground, inherit);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
line-height: 1.5;
|
line-height: var(--chat-input-line-height);
|
||||||
resize: none;
|
resize: none;
|
||||||
overflow-y: auto;
|
overflow-y: hidden;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input::placeholder {
|
.chat-panel .chat-input::placeholder {
|
||||||
color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45));
|
color: var(--vscode-input-placeholderForeground, rgba(255, 255, 255, 0.45));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5861,10 +5875,16 @@ button svg * {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-send-button {
|
.chat-panel .chat-send-button {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 32px;
|
box-sizing: border-box;
|
||||||
height: 32px;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
max-width: 22px;
|
||||||
|
max-height: 22px;
|
||||||
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -5872,16 +5892,17 @@ button svg * {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: var(--vscode-button-background, var(--accent-color));
|
background-color: var(--vscode-button-background, var(--accent-color));
|
||||||
color: var(--vscode-button-foreground, #ffffff);
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
font-size: 18px;
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-send-button:hover:not(:disabled) {
|
.chat-panel .chat-send-button:hover:not(:disabled) {
|
||||||
background-color: var(--vscode-button-hoverBackground, var(--accent-color));
|
background-color: var(--vscode-button-hoverBackground, var(--accent-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-send-button:disabled,
|
.chat-panel .chat-send-button:disabled,
|
||||||
.api-key-submit:disabled {
|
.api-key-submit:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -6059,6 +6080,10 @@ button svg * {
|
|||||||
.chat-input-container {
|
.chat-input-container {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-container {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -731,8 +731,25 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea.style.height = "auto";
|
const styles = getComputedStyle(textarea);
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20;
|
||||||
|
const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160;
|
||||||
|
|
||||||
|
textarea.rows = 1;
|
||||||
|
textarea.style.minHeight = `${minHeight}px`;
|
||||||
|
|
||||||
|
if (textarea.value.trim() === "") {
|
||||||
|
textarea.style.height = `${minHeight}px`;
|
||||||
|
textarea.style.maxHeight = `${minHeight}px`;
|
||||||
|
textarea.style.overflowY = "hidden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.style.maxHeight = `${maxHeight}px`;
|
||||||
|
textarea.style.height = "0px";
|
||||||
|
const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
|
||||||
|
textarea.style.height = `${nextHeight}px`;
|
||||||
|
textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden";
|
||||||
};
|
};
|
||||||
|
|
||||||
this.syncScrollContainer = () => {
|
this.syncScrollContainer = () => {
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ defmodule BDS.AITest do
|
|||||||
|
|
||||||
test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do
|
test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do
|
||||||
{:ok, project} = create_project_fixture("AI Chat")
|
{:ok, project} = create_project_fixture("AI Chat")
|
||||||
:ok = seed_project_content(project.id)
|
_fixtures = seed_project_content(project.id)
|
||||||
|
|
||||||
assert {:ok, _endpoint} =
|
assert {:ok, _endpoint} =
|
||||||
BDS.AI.put_endpoint(
|
BDS.AI.put_endpoint(
|
||||||
@@ -530,8 +530,15 @@ defmodule BDS.AITest do
|
|||||||
message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and
|
message["role"] == "system" and String.contains?(message["content"], "Posts: 1") and
|
||||||
String.contains?(message["content"], "Media: 1") and
|
String.contains?(message["content"], "Media: 1") and
|
||||||
String.contains?(message["content"], "Available blog data tools") and
|
String.contains?(message["content"], "Available blog data tools") and
|
||||||
|
String.contains?(message["content"], "get_blog_stats") and
|
||||||
String.contains?(message["content"], "list_posts") and
|
String.contains?(message["content"], "list_posts") and
|
||||||
String.contains?(message["content"], "list_media")
|
String.contains?(message["content"], "get_media") and
|
||||||
|
String.contains?(message["content"], "view_image") and
|
||||||
|
String.contains?(message["content"], "update_post_metadata") and
|
||||||
|
String.contains?(message["content"], "Available UI Render Tools") and
|
||||||
|
String.contains?(message["content"], "render_chart") and
|
||||||
|
String.contains?(message["content"], "heatmap") and
|
||||||
|
String.contains?(message["content"], "render_tabs")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
tool_descriptions =
|
tool_descriptions =
|
||||||
@@ -540,24 +547,66 @@ defmodule BDS.AITest do
|
|||||||
{get_in(tool, ["function", "name"]), get_in(tool, ["function", "description"])}
|
{get_in(tool, ["function", "name"]), get_in(tool, ["function", "description"])}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert tool_descriptions["blog_stats"] =~ "aggregate"
|
expected_old_app_tools = [
|
||||||
|
"get_blog_stats",
|
||||||
|
"search_posts",
|
||||||
|
"read_post",
|
||||||
|
"read_post_by_slug",
|
||||||
|
"list_posts",
|
||||||
|
"get_media",
|
||||||
|
"list_media",
|
||||||
|
"view_image",
|
||||||
|
"update_post_metadata",
|
||||||
|
"update_media_metadata",
|
||||||
|
"list_tags",
|
||||||
|
"list_categories",
|
||||||
|
"get_post_backlinks",
|
||||||
|
"get_post_outlinks",
|
||||||
|
"get_post_media",
|
||||||
|
"get_media_posts",
|
||||||
|
"render_chart",
|
||||||
|
"render_table",
|
||||||
|
"render_form",
|
||||||
|
"render_card",
|
||||||
|
"render_metric",
|
||||||
|
"render_list",
|
||||||
|
"render_tabs",
|
||||||
|
"render_mindmap"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert Enum.all?(expected_old_app_tools, &Map.has_key?(tool_descriptions, &1))
|
||||||
|
assert tool_descriptions["get_blog_stats"] =~ "comprehensive blog statistics"
|
||||||
assert tool_descriptions["list_posts"] =~ "titles"
|
assert tool_descriptions["list_posts"] =~ "titles"
|
||||||
assert tool_descriptions["list_posts"] =~ "URLs"
|
assert tool_descriptions["list_posts"] =~ "URLs"
|
||||||
assert tool_descriptions["list_media"] =~ "filenames"
|
assert tool_descriptions["list_media"] =~ "filenames"
|
||||||
|
assert tool_descriptions["render_chart"] =~ "interactive chart"
|
||||||
|
assert tool_descriptions["render_chart"] =~ "heatmap"
|
||||||
|
assert tool_descriptions["render_table"] =~ "tabular data"
|
||||||
|
assert tool_descriptions["render_tabs"] =~ "multiple tabs"
|
||||||
|
|
||||||
|
render_chart_schema =
|
||||||
|
first_request.tools
|
||||||
|
|> Enum.find(&(get_in(&1, ["function", "name"]) == "render_chart"))
|
||||||
|
|> get_in(["function", "parameters", "properties"])
|
||||||
|
|
||||||
|
assert get_in(render_chart_schema, ["chartType", "enum"]) == [
|
||||||
|
"bar",
|
||||||
|
"stacked-bar",
|
||||||
|
"line",
|
||||||
|
"area",
|
||||||
|
"pie",
|
||||||
|
"donut",
|
||||||
|
"heatmap"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert get_in(render_chart_schema, ["series", "items", "properties", "segments"]) != nil
|
||||||
|
|
||||||
assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end)
|
assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-stat chat tools expose concrete project data" do
|
test "non-stat chat tools expose concrete project data" do
|
||||||
{:ok, project} = create_project_fixture("Concrete Tools")
|
{:ok, project} = create_project_fixture("Concrete Tools")
|
||||||
:ok = seed_project_content(project.id)
|
%{post: post, media: media} = seed_project_content(project.id)
|
||||||
|
|
||||||
[post] =
|
|
||||||
Repo.all(
|
|
||||||
from post in Post,
|
|
||||||
where: post.project_id == ^project.id,
|
|
||||||
select: post
|
|
||||||
)
|
|
||||||
|
|
||||||
assert %{posts: [listed_post], total: 1} =
|
assert %{posts: [listed_post], total: 1} =
|
||||||
BDS.AI.ChatTools.execute("list_posts", %{"limit" => 5}, project.id)
|
BDS.AI.ChatTools.execute("list_posts", %{"limit" => 5}, project.id)
|
||||||
@@ -567,10 +616,68 @@ defmodule BDS.AITest do
|
|||||||
assert listed_post["url"] == "/posts/#{post.slug}"
|
assert listed_post["url"] == "/posts/#{post.slug}"
|
||||||
assert listed_post["updated_at"] == post.updated_at
|
assert listed_post["updated_at"] == post.updated_at
|
||||||
|
|
||||||
|
assert %{post: read_post} =
|
||||||
|
BDS.AI.ChatTools.execute("read_post", %{"postId" => post.id}, project.id)
|
||||||
|
|
||||||
|
assert read_post["title"] == post.title
|
||||||
|
assert read_post["content"] == post.content
|
||||||
|
|
||||||
assert [listed_media] = BDS.AI.ChatTools.execute("list_media", %{"limit" => 5}, project.id)
|
assert [listed_media] = BDS.AI.ChatTools.execute("list_media", %{"limit" => 5}, project.id)
|
||||||
assert listed_media.filename == "image.png"
|
assert listed_media.filename == "image.png"
|
||||||
assert listed_media.mime_type == "image/png"
|
assert listed_media.mime_type == "image/png"
|
||||||
assert listed_media.updated_at
|
assert listed_media.updated_at
|
||||||
|
|
||||||
|
assert %{media: loaded_media} =
|
||||||
|
BDS.AI.ChatTools.execute("get_media", %{"mediaId" => media.id}, project.id)
|
||||||
|
|
||||||
|
assert loaded_media.id == media.id
|
||||||
|
assert loaded_media.title == "Hero"
|
||||||
|
|
||||||
|
assert %{linked_by: []} =
|
||||||
|
BDS.AI.ChatTools.execute("get_post_backlinks", %{"postId" => post.id}, project.id)
|
||||||
|
|
||||||
|
assert %{links_to: []} =
|
||||||
|
BDS.AI.ChatTools.execute("get_post_outlinks", %{"postId" => post.id}, project.id)
|
||||||
|
|
||||||
|
assert %{media: []} =
|
||||||
|
BDS.AI.ChatTools.execute("get_post_media", %{"postId" => post.id}, project.id)
|
||||||
|
|
||||||
|
assert %{posts: []} =
|
||||||
|
BDS.AI.ChatTools.execute("get_media_posts", %{"mediaId" => media.id}, project.id)
|
||||||
|
|
||||||
|
assert %{success: true, post: updated_post} =
|
||||||
|
BDS.AI.ChatTools.execute(
|
||||||
|
"update_post_metadata",
|
||||||
|
%{"postId" => post.id, "title" => "Updated AI Post"},
|
||||||
|
project.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated_post["title"] == "Updated AI Post"
|
||||||
|
|
||||||
|
assert %{success: true, media: updated_media} =
|
||||||
|
BDS.AI.ChatTools.execute(
|
||||||
|
"update_media_metadata",
|
||||||
|
%{"mediaId" => media.id, "alt" => "Updated alt"},
|
||||||
|
project.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated_media.alt == "Updated alt"
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
type: "chart",
|
||||||
|
chart_type: "heatmap",
|
||||||
|
series: [%{"label" => "2026", "segments" => [%{"label" => "Jan", "value" => 2}]}]
|
||||||
|
} =
|
||||||
|
BDS.AI.ChatTools.execute(
|
||||||
|
"render_chart",
|
||||||
|
%{
|
||||||
|
"chartType" => "heatmap",
|
||||||
|
"series" => [
|
||||||
|
%{"label" => "2026", "segments" => [%{"label" => "Jan", "value" => 2}]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
project.id
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "cancel_chat aborts an in-flight chat turn" do
|
test "cancel_chat aborts an in-flight chat turn" do
|
||||||
@@ -621,6 +728,7 @@ 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()
|
||||||
|
|
||||||
|
post =
|
||||||
Repo.insert!(
|
Repo.insert!(
|
||||||
Post.changeset(%Post{}, %{
|
Post.changeset(%Post{}, %{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
@@ -636,6 +744,7 @@ defmodule BDS.AITest do
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
media =
|
||||||
Repo.insert!(
|
Repo.insert!(
|
||||||
Media.changeset(%Media{}, %{
|
Media.changeset(%Media{}, %{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
@@ -644,6 +753,7 @@ defmodule BDS.AITest do
|
|||||||
original_name: "image.png",
|
original_name: "image.png",
|
||||||
mime_type: "image/png",
|
mime_type: "image/png",
|
||||||
size: 128,
|
size: 128,
|
||||||
|
title: "Hero",
|
||||||
file_path: "media/image.png",
|
file_path: "media/image.png",
|
||||||
sidecar_path: "media/image.png.meta",
|
sidecar_path: "media/image.png.meta",
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -651,6 +761,6 @@ defmodule BDS.AITest do
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
:ok
|
%{post: post, media: media}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2247,6 +2247,58 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert css =~ "line-height: 1.35;"
|
assert css =~ "line-height: 1.35;"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "chat editor keeps empty input single-line until content grows" do
|
||||||
|
assert {:ok, conversation} = AI.start_chat(%{title: "Input Sizing", model: "gpt-4.1"})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||||
|
|
||||||
|
html =
|
||||||
|
render_click(view, "pin_sidebar_item", %{
|
||||||
|
"route" => "chat",
|
||||||
|
"id" => conversation.id,
|
||||||
|
"title" => conversation.title,
|
||||||
|
"subtitle" => conversation.model || "chat"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ ~s(rows="1")
|
||||||
|
assert html =~ ~s(class="chat-input chat-surface-input")
|
||||||
|
|
||||||
|
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__))
|
||||||
|
assert css =~ "--chat-input-line-height: 20px;"
|
||||||
|
assert css =~ "--chat-input-min-height: 20px;"
|
||||||
|
assert css =~ ".chat-panel .chat-input-container"
|
||||||
|
assert css =~ "padding: 8px 16px;"
|
||||||
|
assert css =~ "padding: 6px 8px;"
|
||||||
|
assert css =~ ".chat-panel .chat-input-wrapper"
|
||||||
|
assert css =~ "min-height: 30px;"
|
||||||
|
assert css =~ "padding: 4px 6px;"
|
||||||
|
assert css =~ ".chat-panel .chat-input"
|
||||||
|
assert css =~ "box-sizing: border-box;"
|
||||||
|
assert css =~ "margin: 0;"
|
||||||
|
assert css =~ "height: var(--chat-input-min-height);"
|
||||||
|
assert css =~ "min-height: var(--chat-input-min-height);"
|
||||||
|
assert css =~ "overflow-y: hidden;"
|
||||||
|
assert css =~ ".chat-panel .chat-send-button"
|
||||||
|
assert css =~ "width: 22px;"
|
||||||
|
assert css =~ "height: 22px;"
|
||||||
|
assert css =~ "max-width: 22px;"
|
||||||
|
assert css =~ "max-height: 22px;"
|
||||||
|
assert css =~ "padding: 0;"
|
||||||
|
|
||||||
|
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__))
|
||||||
|
|
||||||
|
assert live_js =~
|
||||||
|
"minHeight = parseFloat(styles.getPropertyValue(\"--chat-input-min-height\"))"
|
||||||
|
|
||||||
|
assert live_js =~ "textarea.value.trim() === \"\""
|
||||||
|
assert live_js =~ "textarea.rows = 1;"
|
||||||
|
assert live_js =~ "textarea.style.minHeight = `${minHeight}px`;"
|
||||||
|
assert live_js =~ "textarea.style.height = `${minHeight}px`;"
|
||||||
|
assert live_js =~ "textarea.style.maxHeight = `${minHeight}px`;"
|
||||||
|
assert live_js =~ "textarea.style.height = \"0px\";"
|
||||||
|
assert live_js =~ "textarea.style.overflowY = nextHeight >= maxHeight ? \"auto\" : \"hidden\""
|
||||||
|
end
|
||||||
|
|
||||||
test "chat editor groups selector models by provider and uses catalog labels" do
|
test "chat editor groups selector models by provider and uses catalog labels" do
|
||||||
updated_at = Persistence.now_ms()
|
updated_at = Persistence.now_ms()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ defmodule BDS.DesktopTest do
|
|||||||
|
|
||||||
import Plug.Test
|
import Plug.Test
|
||||||
|
|
||||||
|
defmodule FakeShutdown do
|
||||||
|
def request_quit do
|
||||||
|
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :quit_requested)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "desktop configuration no longer uses a pending adapter" do
|
test "desktop configuration no longer uses a pending adapter" do
|
||||||
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
|
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
|
||||||
end
|
end
|
||||||
@@ -99,6 +106,38 @@ defmodule BDS.DesktopTest do
|
|||||||
assert menu_item(groups, :metadata_diff).shortcut == nil
|
assert menu_item(groups, :metadata_diff).shortcut == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "native menu quit requests app-owned shutdown" do
|
||||||
|
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
|
||||||
|
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)
|
||||||
|
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_module, FakeShutdown)
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_test_pid, self())
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
restore_env(:desktop_shutdown_module, previous_module)
|
||||||
|
restore_env(:desktop_shutdown_test_pid, previous_pid)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:noreply, %{}} = BDS.Desktop.MenuBar.handle_event("quit", %{})
|
||||||
|
assert_receive :quit_requested
|
||||||
|
end
|
||||||
|
|
||||||
|
test "icon menu quit requests app-owned shutdown" do
|
||||||
|
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
|
||||||
|
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)
|
||||||
|
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_module, FakeShutdown)
|
||||||
|
Application.put_env(:bds, :desktop_shutdown_test_pid, self())
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
restore_env(:desktop_shutdown_module, previous_module)
|
||||||
|
restore_env(:desktop_shutdown_test_pid, previous_pid)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:noreply, %{}} = BDS.Desktop.Menu.handle_event("quit", %{})
|
||||||
|
assert_receive :quit_requested
|
||||||
|
end
|
||||||
|
|
||||||
test "desktop root html is a LiveView shell and references only the live bootstrap assets" do
|
test "desktop root html is a LiveView shell and references only the live bootstrap assets" do
|
||||||
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
|
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
|
||||||
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
|
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))
|
||||||
@@ -178,4 +217,7 @@ defmodule BDS.DesktopTest do
|
|||||||
Image.new!(3, 2, color: [255, 0, 0])
|
Image.new!(3, 2, color: [255, 0, 0])
|
||||||
|> Image.write!(:memory, suffix: ".jpg", quality: 85)
|
|> Image.write!(:memory, suffix: ".jpg", quality: 85)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp restore_env(key, nil), do: Application.delete_env(:bds, key)
|
||||||
|
defp restore_env(key, value), do: Application.put_env(:bds, key, value)
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user