From 57bdcef9908fc907d46320360bf5338c730b4e49 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 25 Jun 2026 15:50:08 +0200 Subject: [PATCH] fix: more refactorings for cleaning code --- lib/bds/ai/chat.ex | 95 +--- lib/bds/ai/chat_title_generator.ex | 99 ++++ lib/bds/ai/chat_tool_query_helpers.ex | 49 ++ lib/bds/ai/chat_tool_schemas.ex | 350 ++++++++++++++ lib/bds/ai/chat_tools.ex | 507 +++----------------- lib/bds/desktop/shell_live.ex | 82 +--- lib/bds/desktop/shell_live/content_state.ex | 83 ++++ test/bds/csm036_file_split_test.exs | 58 +++ 8 files changed, 710 insertions(+), 613 deletions(-) create mode 100644 lib/bds/ai/chat_title_generator.ex create mode 100644 lib/bds/ai/chat_tool_query_helpers.ex create mode 100644 lib/bds/ai/chat_tool_schemas.ex create mode 100644 lib/bds/desktop/shell_live/content_state.ex create mode 100644 test/bds/csm036_file_split_test.exs diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index 26a9ddd..b9cee80 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -9,6 +9,7 @@ defmodule BDS.AI.Chat do alias BDS.AI.CatalogProvider alias BDS.AI.ChatConversation alias BDS.AI.ChatMessage + alias BDS.AI.ChatTitleGenerator alias BDS.AI.ChatTools alias BDS.AI.InFlight alias BDS.AI.OpenAICompatibleRuntime @@ -24,8 +25,6 @@ defmodule BDS.AI.Chat do @default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts." @default_max_output_tokens 16_384 - @title_max_output_tokens 256 - @chat_title_max_length 30 @chat_max_tool_rounds 10 @chat_await_timeout_margin_ms 5_000 @default_context_window 128_000 @@ -34,7 +33,7 @@ defmodule BDS.AI.Chat do def start_chat(attrs \\ %{}) when is_map(attrs) do now = Persistence.now_ms() model = MapUtils.attr(attrs, :model) - title = MapUtils.attr(attrs, :title) || generated_chat_title(model) + title = MapUtils.attr(attrs, :title) || ChatTitleGenerator.generated_chat_title(model) %ChatConversation{} |> ChatConversation.changeset(%{ @@ -430,11 +429,11 @@ defmodule BDS.AI.Chat do conversation = Repo.get!(ChatConversation, conversation_id) cond do - chat_user_message_count(conversation_id) < 1 -> + ChatTitleGenerator.chat_user_message_count(conversation_id) < 1 -> Logger.debug("Chat title generation skipped reason=:no_user_messages") {:ok, reply} - not generated_chat_title?(conversation.title, conversation.model) -> + not ChatTitleGenerator.generated_chat_title?(conversation.title, conversation.model) -> Logger.debug( "Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}" ) @@ -444,7 +443,7 @@ defmodule BDS.AI.Chat do true -> Logger.debug("Chat title generation requested conversation_id=#{conversation_id}") - case generate_chat_title(user_content, opts) do + case ChatTitleGenerator.generate_chat_title(user_content, opts) do {:ok, title} when is_binary(title) and title != "" -> now = Persistence.now_ms() @@ -465,87 +464,6 @@ defmodule BDS.AI.Chat do end end - defp generate_chat_title(user_content, opts) when is_binary(user_content) do - runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) - - with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts), - :ok <- Runtime.validate_target(:chat_title, model, mode), - request <- build_chat_title_request(user_content, model), - {:ok, response} <- - runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do - title = sanitize_chat_title(Map.get(response, :content)) - - if title == "" do - Logger.warning("Chat title generation returned an empty title", - model: model, - content: inspect(Map.get(response, :content)), - usage: inspect(Map.get(response, :usage)) - ) - end - - {:ok, title} - else - {:error, reason} = error -> - Logger.warning("Chat title generation failed", reason: inspect(reason)) - error - - other -> - Logger.warning("Chat title generation failed", reason: inspect(other)) - other - end - end - - defp build_chat_title_request(user_content, model) do - %{ - operation: :chat_title, - model: model, - max_output_tokens: @title_max_output_tokens, - messages: [ - %{ - "role" => "system", - "content" => - "Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text." - }, - %{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"} - ] - } - end - - defp sanitize_chat_title(title) when is_binary(title) do - title = - title - |> String.trim() - |> String.trim_leading("\"") - |> String.trim_leading("'") - |> String.trim_trailing("\"") - |> String.trim_trailing("'") - |> String.trim_trailing(".") - |> String.trim_trailing("!") - |> String.trim_trailing("?") - - if String.length(title) > @chat_title_max_length do - String.slice(title, 0, @chat_title_max_length - 3) <> "..." - else - title - end - end - - defp sanitize_chat_title(_title), do: "" - - defp chat_user_message_count(conversation_id) do - Repo.aggregate( - from(message in ChatMessage, - where: message.conversation_id == ^conversation_id and message.role == :user - ), - :count, - :id - ) - end - - defp generated_chat_title?(title, model) do - title in [generated_chat_title(nil), generated_chat_title(model)] - end - defp chat_round( _conversation, _messages, @@ -882,9 +800,6 @@ defmodule BDS.AI.Chat do ) end - defp generated_chat_title(nil), do: "New Chat" - defp generated_chat_title(model), do: "Chat with #{model}" - defp load_chat_messages(conversation_id) do Repo.all( from message in ChatMessage, diff --git a/lib/bds/ai/chat_title_generator.ex b/lib/bds/ai/chat_title_generator.ex new file mode 100644 index 0000000..d7886dd --- /dev/null +++ b/lib/bds/ai/chat_title_generator.ex @@ -0,0 +1,99 @@ +defmodule BDS.AI.ChatTitleGenerator do + @moduledoc false + + require Logger + + alias BDS.AI.ChatMessage + alias BDS.AI.OpenAICompatibleRuntime + alias BDS.AI.Runtime + alias BDS.Repo + + import Ecto.Query + + @title_max_output_tokens 256 + @chat_title_max_length 30 + + def generate_chat_title(user_content, opts) when is_binary(user_content) do + runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) + + with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts), + :ok <- Runtime.validate_target(:chat_title, model, mode), + request <- build_chat_title_request(user_content, model), + {:ok, response} <- + runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do + title = sanitize_chat_title(Map.get(response, :content)) + + if title == "" do + Logger.warning("Chat title generation returned an empty title", + model: model, + content: inspect(Map.get(response, :content)), + usage: inspect(Map.get(response, :usage)) + ) + end + + {:ok, title} + else + {:error, reason} = error -> + Logger.warning("Chat title generation failed", reason: inspect(reason)) + error + + other -> + Logger.warning("Chat title generation failed", reason: inspect(other)) + other + end + end + + def build_chat_title_request(user_content, model) do + %{ + operation: :chat_title, + model: model, + max_output_tokens: @title_max_output_tokens, + messages: [ + %{ + "role" => "system", + "content" => + "Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text." + }, + %{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"} + ] + } + end + + def sanitize_chat_title(title) when is_binary(title) do + title = + title + |> String.trim() + |> String.trim_leading("\"") + |> String.trim_leading("'") + |> String.trim_trailing("\"") + |> String.trim_trailing("'") + |> String.trim_trailing(".") + |> String.trim_trailing("!") + |> String.trim_trailing("?") + + if String.length(title) > @chat_title_max_length do + String.slice(title, 0, @chat_title_max_length - 3) <> "..." + else + title + end + end + + def sanitize_chat_title(_title), do: "" + + def chat_user_message_count(conversation_id) do + Repo.aggregate( + from(message in ChatMessage, + where: message.conversation_id == ^conversation_id and message.role == :user + ), + :count, + :id + ) + end + + def generated_chat_title?(title, model) do + title in [generated_chat_title(nil), generated_chat_title(model)] + end + + def generated_chat_title(nil), do: "New Chat" + def generated_chat_title(model), do: "Chat with #{model}" +end diff --git a/lib/bds/ai/chat_tool_query_helpers.ex b/lib/bds/ai/chat_tool_query_helpers.ex new file mode 100644 index 0000000..46177cb --- /dev/null +++ b/lib/bds/ai/chat_tool_query_helpers.ex @@ -0,0 +1,49 @@ +defmodule BDS.AI.ChatToolQueryHelpers do + @moduledoc false + + alias BDS.Search + + def normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value + def normalize_limit(_value), do: 10 + + def normalize_offset(value) when is_integer(value) and value >= 0, do: value + def normalize_offset(_value), do: 0 + + def search_filters(arguments) do + %{} + |> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"])) + |> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"])) + |> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"])) + |> BDS.MapUtils.maybe_put( + :missing_translation_language, + BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"]) + ) + |> BDS.MapUtils.maybe_put(:year, arguments["year"]) + |> BDS.MapUtils.maybe_put(:month, arguments["month"]) + |> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"])) + |> Map.put(:offset, normalize_offset(arguments["offset"])) + |> Map.put(:limit, normalize_limit(arguments["limit"])) + end + + def search_all_counted_posts(project_id, arguments) do + filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1) + {:ok, %{total: total}} = Search.search_posts(project_id, "", filters) + + filters = Map.put(filters, :limit, max(total, 1)) + {:ok, result} = Search.search_posts(project_id, "", filters) + + result + end + + def search_result(project_id, query, filters, mapper) do + {:ok, result} = Search.search_posts(project_id, query, filters) + + %{ + posts: Enum.map(result.posts, mapper), + total: result.total, + offset: result.offset, + limit: result.limit, + has_more: result.offset + result.limit < result.total + } + end +end diff --git a/lib/bds/ai/chat_tool_schemas.ex b/lib/bds/ai/chat_tool_schemas.ex new file mode 100644 index 0000000..9567ddb --- /dev/null +++ b/lib/bds/ai/chat_tool_schemas.ex @@ -0,0 +1,350 @@ +defmodule BDS.AI.ChatToolSchemas do + @moduledoc false + + def tool_spec(name, description, parameters) do + %{ + "type" => "function", + "function" => %{ + "name" => name, + "description" => description, + "parameters" => parameters + } + } + end + + def limit_schema do + %{ + "type" => "object", + "properties" => %{ + "limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50} + } + } + end + + def 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 + + def 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 + + def post_id_schema do + %{ + "type" => "object", + "properties" => %{"postId" => %{"type" => "string"}}, + "required" => ["postId"] + } + end + + def media_id_schema(extra_properties \\ %{}) do + %{ + "type" => "object", + "properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties), + "required" => ["mediaId"] + } + end + + def 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 + + def 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 + + def render_table_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string", "description" => "Optional table title"}, + "columns" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "Column header names" + }, + "rows" => %{ + "type" => "array", + "items" => %{"type" => "array", "items" => %{"type" => "string"}}, + "description" => "Table rows, each row is an array of cell values" + } + } + } + end + + def render_chart_schema do + %{ + "type" => "object", + "properties" => %{ + "chartType" => %{ + "type" => "string", + "enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"], + "description" => + "The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations." + }, + "title" => %{"type" => "string", "description" => "Optional chart title"}, + "series" => %{ + "type" => "array", + "description" => "Array of data points.", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string", "description" => "Data point label"}, + "value" => %{"type" => "number", "description" => "Data point value"}, + "segments" => %{ + "type" => "array", + "description" => + "Segments within this data point. Required for stacked-bar and heatmap charts.", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string"}, + "value" => %{"type" => "number"} + }, + "required" => ["label", "value"] + } + } + }, + "required" => ["label"] + } + } + }, + "required" => ["chartType", "series"] + } + end + + def render_form_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string", "description" => "Optional form title"}, + "fields" => %{ + "type" => "array", + "description" => "Form fields to display", + "items" => %{ + "type" => "object", + "properties" => %{ + "key" => %{"type" => "string", "description" => "Field identifier"}, + "label" => %{"type" => "string", "description" => "Field label shown to user"}, + "inputType" => %{ + "type" => "string", + "enum" => ["text", "textarea", "select", "checkbox", "date", "number"], + "description" => "Type of input control" + }, + "placeholder" => %{"type" => "string", "description" => "Placeholder text"}, + "defaultValue" => %{"type" => "string", "description" => "Default value"}, + "options" => %{ + "type" => "array", + "description" => "Options for select fields", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string"}, + "value" => %{"type" => "string"} + } + } + }, + "required" => %{"type" => "boolean", "description" => "Whether the field is required"} + }, + "required" => ["key", "label", "inputType"] + } + }, + "submitLabel" => %{"type" => "string", "description" => "Label for the submit button"}, + "submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"} + } + } + end + + def render_card_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string", "description" => "Card title"}, + "subtitle" => %{"type" => "string", "description" => "Optional subtitle"}, + "body" => %{"type" => "string", "description" => "Card body text (supports markdown)"}, + "actions" => %{ + "type" => "array", + "description" => "Optional action buttons on the card", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string", "description" => "Button label"}, + "action" => %{"type" => "string", "description" => "Action name to dispatch"}, + "payload" => %{ + "type" => "object", + "description" => "Optional action payload" + } + }, + "required" => ["label", "action"] + } + } + } + } + end + + def render_metric_schema do + %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string", "description" => "Metric label"}, + "value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"} + } + } + end + + def render_list_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string", "description" => "Optional list title"}, + "items" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "List items" + } + } + } + end + + def render_tabs_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string", "description" => "Optional tabs title"}, + "tabs" => %{ + "type" => "array", + "description" => "Array of tabs", + "items" => %{ + "type" => "object", + "properties" => %{ + "label" => %{"type" => "string", "description" => "Tab label"}, + "content" => %{ + "type" => "array", + "description" => "Content items within the tab", + "items" => %{ + "type" => "object", + "properties" => %{ + "type" => %{ + "type" => "string", + "enum" => ["text", "metric", "list", "chart", "table"], + "description" => "Content type" + }, + "text" => %{"type" => "string", "description" => "Text content (for type text)"}, + "label" => %{"type" => "string", "description" => "Label (for type metric)"}, + "value" => %{"type" => "string", "description" => "Display value (for type metric)"}, + "title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"}, + "items" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "Items (for type list)" + }, + "chartType" => %{ + "type" => "string", + "enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"], + "description" => "Chart type (for type chart)" + }, + "series" => %{ + "type" => "array", + "description" => "Data series (for type chart)" + }, + "columns" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "Column headers (for type table)" + }, + "rows" => %{ + "type" => "array", + "items" => %{"type" => "array", "items" => %{"type" => "string"}}, + "description" => "Table rows (for type table)" + } + }, + "required" => ["type"] + } + } + }, + "required" => ["label", "content"] + } + } + } + } + end + + def render_mindmap_schema do + %{ + "type" => "object", + "properties" => %{ + "title" => %{"type" => "string", "description" => "Optional mind map title"}, + "nodes" => %{ + "type" => "array", + "description" => "Flat array of nodes. The first node is the root. Each node references children by ID.", + "items" => %{ + "type" => "object", + "properties" => %{ + "id" => %{"type" => "string", "description" => "Unique node identifier"}, + "label" => %{"type" => "string", "description" => "Node label text"}, + "children" => %{ + "type" => "array", + "items" => %{"type" => "string"}, + "description" => "IDs of child nodes" + } + }, + "required" => ["id", "label"] + } + } + } + } + end +end diff --git a/lib/bds/ai/chat_tools.ex b/lib/bds/ai/chat_tools.ex index 9d627b0..d8e3b14 100644 --- a/lib/bds/ai/chat_tools.ex +++ b/lib/bds/ai/chat_tools.ex @@ -4,6 +4,8 @@ defmodule BDS.AI.ChatTools do import Ecto.Query alias BDS.AI.Chat + alias BDS.AI.ChatToolQueryHelpers, as: QueryHelpers + alias BDS.AI.ChatToolSchemas, as: Schemas alias BDS.Media, as: MediaContext alias BDS.Media.Media alias BDS.MCP.Queries @@ -12,7 +14,6 @@ defmodule BDS.AI.ChatTools do alias BDS.Posts.PostMedia alias BDS.Projects.Project alias BDS.Repo - alias BDS.Search @spec execute(String.t(), map(), String.t() | nil) :: map() def execute("blog_stats", _arguments, project_id) do @@ -58,8 +59,14 @@ defmodule BDS.AI.ChatTools do def execute("search_posts", arguments, project_id) do project_id = project_id || active_project_id() - filters = search_filters(arguments) - search_result(project_id, arguments["query"] || "", filters, &Queries.post_summary/1) + filters = QueryHelpers.search_filters(arguments) + + QueryHelpers.search_result( + project_id, + arguments["query"] || "", + filters, + &Queries.post_summary/1 + ) end def execute("read_post_by_slug", arguments, project_id) do @@ -81,11 +88,11 @@ defmodule BDS.AI.ChatTools do 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}) + limit = QueryHelpers.normalize_limit(arguments["limit"]) + offset = QueryHelpers.normalize_offset(arguments["offset"]) + filters = QueryHelpers.search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset}) - search_result(project_id, "", filters, fn post -> + QueryHelpers.search_result(project_id, "", filters, fn post -> post |> Queries.post_summary() |> Map.put("url", "/posts/#{post.slug}") @@ -95,7 +102,7 @@ defmodule BDS.AI.ChatTools do def execute("list_media", arguments, project_id) do project_id = project_id || active_project_id() - limit = normalize_limit(arguments["limit"]) + limit = QueryHelpers.normalize_limit(arguments["limit"]) Repo.all( from(media in Media, @@ -194,7 +201,7 @@ defmodule BDS.AI.ChatTools do 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) - result = search_all_counted_posts(project_id, arguments) + result = QueryHelpers.search_all_counted_posts(project_id, arguments) groups = result.posts @@ -321,7 +328,7 @@ defmodule BDS.AI.ChatTools do %{ name: "blog_stats", spec: - tool_spec("blog_stats", "Return aggregate blog statistics", %{ + Schemas.tool_spec("blog_stats", "Return aggregate blog statistics", %{ "type" => "object", "properties" => %{} }) @@ -329,7 +336,7 @@ defmodule BDS.AI.ChatTools do %{ name: "get_blog_stats", spec: - tool_spec( + Schemas.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" => %{}} @@ -338,7 +345,7 @@ defmodule BDS.AI.ChatTools do %{ name: "check_term", spec: - tool_spec( + Schemas.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.", %{ @@ -351,16 +358,16 @@ defmodule BDS.AI.ChatTools do %{ name: "search_posts", spec: - tool_spec( + Schemas.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) + Schemas.post_search_schema(true) ) }, %{ name: "read_post", spec: - tool_spec( + Schemas.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.", %{ @@ -373,7 +380,7 @@ defmodule BDS.AI.ChatTools do %{ name: "read_post_by_slug", spec: - tool_spec( + Schemas.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.", %{ @@ -386,37 +393,37 @@ defmodule BDS.AI.ChatTools do %{ name: "list_posts", spec: - tool_spec( + Schemas.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) + Schemas.post_search_schema(false) ) }, %{ name: "get_media", spec: - tool_spec( + Schemas.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() + Schemas.media_id_schema() ) }, %{ name: "list_media", spec: - tool_spec( + Schemas.tool_spec( "list_media", "List concrete media data in the active project, including titles, filenames, MIME types, and update times.", - limit_schema() + Schemas.limit_schema() ) }, %{ name: "view_image", spec: - tool_spec( + Schemas.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(%{ + Schemas.media_id_schema(%{ "size" => %{"type" => "string", "enum" => ["small", "medium", "large"]} }) ) @@ -424,25 +431,25 @@ defmodule BDS.AI.ChatTools do %{ name: "update_post_metadata", spec: - tool_spec( + Schemas.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() + Schemas.update_post_metadata_schema() ) }, %{ name: "update_media_metadata", spec: - tool_spec( + Schemas.tool_spec( "update_media_metadata", "Update metadata for a media file: title, alt text, caption, or tags.", - update_media_metadata_schema() + Schemas.update_media_metadata_schema() ) }, %{ name: "list_tags", spec: - tool_spec( + Schemas.tool_spec( "list_tags", "List all tags used across blog posts with post counts.", %{ @@ -454,7 +461,7 @@ defmodule BDS.AI.ChatTools do %{ name: "list_categories", spec: - tool_spec( + Schemas.tool_spec( "list_categories", "List all categories used across blog posts with post counts.", %{"type" => "object", "properties" => %{}} @@ -463,46 +470,46 @@ defmodule BDS.AI.ChatTools do %{ name: "count_posts", spec: - tool_spec( + Schemas.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() + Schemas.count_posts_schema() ) }, %{ name: "get_post_backlinks", spec: - tool_spec( + Schemas.tool_spec( "get_post_backlinks", "Get all posts that link to a specific post.", - post_id_schema() + Schemas.post_id_schema() ) }, %{ name: "get_post_outlinks", spec: - tool_spec( + Schemas.tool_spec( "get_post_outlinks", "Get all posts that a specific post links to.", - post_id_schema() + Schemas.post_id_schema() ) }, %{ name: "get_post_media", spec: - tool_spec( + Schemas.tool_spec( "get_post_media", "Get media files linked to a specific post.", - post_id_schema() + Schemas.post_id_schema() ) }, %{ name: "get_media_posts", spec: - tool_spec( + Schemas.tool_spec( "get_media_posts", "Get posts that use a specific media file.", - media_id_schema() + Schemas.media_id_schema() ) } ] @@ -515,73 +522,73 @@ defmodule BDS.AI.ChatTools do %{ name: "render_card", spec: - tool_spec( + Schemas.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() + Schemas.render_card_schema() ) }, %{ name: "render_table", spec: - tool_spec( + Schemas.tool_spec( "render_table", "Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.", - render_table_schema() + Schemas.render_table_schema() ) }, %{ name: "render_chart", spec: - tool_spec( + Schemas.tool_spec( "render_chart", "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() + Schemas.render_chart_schema() ) }, %{ name: "render_form", spec: - tool_spec( + Schemas.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() + Schemas.render_form_schema() ) }, %{ name: "render_metric", spec: - tool_spec( + Schemas.tool_spec( "render_metric", "Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.", - render_metric_schema() + Schemas.render_metric_schema() ) }, %{ name: "render_list", spec: - tool_spec( + Schemas.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() + Schemas.render_list_schema() ) }, %{ name: "render_tabs", spec: - tool_spec( + Schemas.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() + Schemas.render_tabs_schema() ) }, %{ name: "render_mindmap", spec: - tool_spec( + Schemas.tool_spec( "render_mindmap", "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() + Schemas.render_mindmap_schema() ) } ] @@ -590,394 +597,6 @@ defmodule BDS.AI.ChatTools do end end - defp tool_spec(name, description, parameters) do - %{ - "type" => "function", - "function" => %{ - "name" => name, - "description" => description, - "parameters" => parameters - } - } - end - - defp limit_schema do - %{ - "type" => "object", - "properties" => %{ - "limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50} - } - } - end - - defp post_search_schema(require_query) do - schema = %{ - "type" => "object", - "properties" => %{ - "query" => %{"type" => "string"}, - "status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]}, - "category" => %{"type" => "string"}, - "tags" => %{"type" => "array", "items" => %{"type" => "string"}}, - "language" => %{"type" => "string"}, - "missingTranslationLanguage" => %{"type" => "string"}, - "year" => %{"type" => "integer"}, - "month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12}, - "limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}, - "offset" => %{"type" => "integer", "minimum" => 0} - } - } - - if require_query, do: Map.put(schema, "required", ["query"]), else: schema - end - - defp count_posts_schema do - %{ - "type" => "object", - "properties" => %{ - "groupBy" => %{ - "type" => "array", - "items" => %{ - "type" => "string", - "enum" => ["year", "month", "tag", "category", "status"] - } - }, - "year" => %{"type" => "integer"}, - "month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12}, - "status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]}, - "category" => %{"type" => "string"}, - "tags" => %{"type" => "array", "items" => %{"type" => "string"}} - }, - "required" => ["groupBy"] - } - end - - defp post_id_schema do - %{ - "type" => "object", - "properties" => %{"postId" => %{"type" => "string"}}, - "required" => ["postId"] - } - end - - defp media_id_schema(extra_properties \\ %{}) do - %{ - "type" => "object", - "properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties), - "required" => ["mediaId"] - } - end - - defp update_post_metadata_schema do - %{ - "type" => "object", - "properties" => %{ - "postId" => %{"type" => "string"}, - "title" => %{"type" => "string"}, - "excerpt" => %{"type" => "string"}, - "tags" => %{"type" => "array", "items" => %{"type" => "string"}}, - "categories" => %{"type" => "array", "items" => %{"type" => "string"}} - }, - "required" => ["postId"] - } - end - - defp update_media_metadata_schema do - %{ - "type" => "object", - "properties" => %{ - "mediaId" => %{"type" => "string"}, - "title" => %{"type" => "string"}, - "alt" => %{"type" => "string"}, - "caption" => %{"type" => "string"}, - "tags" => %{"type" => "array", "items" => %{"type" => "string"}} - }, - "required" => ["mediaId"] - } - end - - defp render_table_schema do - %{ - "type" => "object", - "properties" => %{ - "title" => %{"type" => "string", "description" => "Optional table title"}, - "columns" => %{ - "type" => "array", - "items" => %{"type" => "string"}, - "description" => "Column header names" - }, - "rows" => %{ - "type" => "array", - "items" => %{"type" => "array", "items" => %{"type" => "string"}}, - "description" => "Table rows, each row is an array of cell values" - } - } - } - end - - defp render_chart_schema do - %{ - "type" => "object", - "properties" => %{ - "chartType" => %{ - "type" => "string", - "enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"], - "description" => - "The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations." - }, - "title" => %{"type" => "string", "description" => "Optional chart title"}, - "series" => %{ - "type" => "array", - "description" => "Array of data points.", - "items" => %{ - "type" => "object", - "properties" => %{ - "label" => %{"type" => "string", "description" => "Data point label"}, - "value" => %{"type" => "number", "description" => "Data point value"}, - "segments" => %{ - "type" => "array", - "description" => - "Segments within this data point. Required for stacked-bar and heatmap charts.", - "items" => %{ - "type" => "object", - "properties" => %{ - "label" => %{"type" => "string"}, - "value" => %{"type" => "number"} - }, - "required" => ["label", "value"] - } - } - }, - "required" => ["label"] - } - } - }, - "required" => ["chartType", "series"] - } - end - - defp render_form_schema do - %{ - "type" => "object", - "properties" => %{ - "title" => %{"type" => "string", "description" => "Optional form title"}, - "fields" => %{ - "type" => "array", - "description" => "Form fields to display", - "items" => %{ - "type" => "object", - "properties" => %{ - "key" => %{"type" => "string", "description" => "Field identifier"}, - "label" => %{"type" => "string", "description" => "Field label shown to user"}, - "inputType" => %{ - "type" => "string", - "enum" => ["text", "textarea", "select", "checkbox", "date", "number"], - "description" => "Type of input control" - }, - "placeholder" => %{"type" => "string", "description" => "Placeholder text"}, - "defaultValue" => %{"type" => "string", "description" => "Default value"}, - "options" => %{ - "type" => "array", - "description" => "Options for select fields", - "items" => %{ - "type" => "object", - "properties" => %{ - "label" => %{"type" => "string"}, - "value" => %{"type" => "string"} - } - } - }, - "required" => %{"type" => "boolean", "description" => "Whether the field is required"} - }, - "required" => ["key", "label", "inputType"] - } - }, - "submitLabel" => %{"type" => "string", "description" => "Label for the submit button"}, - "submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"} - } - } - end - - defp render_card_schema do - %{ - "type" => "object", - "properties" => %{ - "title" => %{"type" => "string", "description" => "Card title"}, - "subtitle" => %{"type" => "string", "description" => "Optional subtitle"}, - "body" => %{"type" => "string", "description" => "Card body text (supports markdown)"}, - "actions" => %{ - "type" => "array", - "description" => "Optional action buttons on the card", - "items" => %{ - "type" => "object", - "properties" => %{ - "label" => %{"type" => "string", "description" => "Button label"}, - "action" => %{"type" => "string", "description" => "Action name to dispatch"}, - "payload" => %{ - "type" => "object", - "description" => "Optional action payload" - } - }, - "required" => ["label", "action"] - } - } - } - } - end - - defp render_metric_schema do - %{ - "type" => "object", - "properties" => %{ - "label" => %{"type" => "string", "description" => "Metric label"}, - "value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"} - } - } - end - - defp render_list_schema do - %{ - "type" => "object", - "properties" => %{ - "title" => %{"type" => "string", "description" => "Optional list title"}, - "items" => %{ - "type" => "array", - "items" => %{"type" => "string"}, - "description" => "List items" - } - } - } - end - - defp render_tabs_schema do - %{ - "type" => "object", - "properties" => %{ - "title" => %{"type" => "string", "description" => "Optional tabs title"}, - "tabs" => %{ - "type" => "array", - "description" => "Array of tabs", - "items" => %{ - "type" => "object", - "properties" => %{ - "label" => %{"type" => "string", "description" => "Tab label"}, - "content" => %{ - "type" => "array", - "description" => "Content items within the tab", - "items" => %{ - "type" => "object", - "properties" => %{ - "type" => %{ - "type" => "string", - "enum" => ["text", "metric", "list", "chart", "table"], - "description" => "Content type" - }, - "text" => %{"type" => "string", "description" => "Text content (for type text)"}, - "label" => %{"type" => "string", "description" => "Label (for type metric)"}, - "value" => %{"type" => "string", "description" => "Display value (for type metric)"}, - "title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"}, - "items" => %{ - "type" => "array", "items" => %{"type" => "string"}, - "description" => "Items (for type list)" - }, - "chartType" => %{ - "type" => "string", - "enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"], - "description" => "Chart type (for type chart)" - }, - "series" => %{ - "type" => "array", - "description" => "Data series (for type chart)" - }, - "columns" => %{ - "type" => "array", "items" => %{"type" => "string"}, - "description" => "Column headers (for type table)" - }, - "rows" => %{ - "type" => "array", "items" => %{"type" => "array", "items" => %{"type" => "string"}}, - "description" => "Table rows (for type table)" - } - }, - "required" => ["type"] - } - } - }, - "required" => ["label", "content"] - } - } - } - } - end - - defp render_mindmap_schema do - %{ - "type" => "object", - "properties" => %{ - "title" => %{"type" => "string", "description" => "Optional mind map title"}, - "nodes" => %{ - "type" => "array", - "description" => "Flat array of nodes. The first node is the root. Each node references children by ID.", - "items" => %{ - "type" => "object", - "properties" => %{ - "id" => %{"type" => "string", "description" => "Unique node identifier"}, - "label" => %{"type" => "string", "description" => "Node label text"}, - "children" => %{ - "type" => "array", - "items" => %{"type" => "string"}, - "description" => "IDs of child nodes" - } - }, - "required" => ["id", "label"] - } - } - } - } - end - - defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value - defp normalize_limit(_value), do: 10 - - defp normalize_offset(value) when is_integer(value) and value >= 0, do: value - defp normalize_offset(_value), do: 0 - - defp search_filters(arguments) do - %{} - |> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"])) - |> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"])) - |> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"])) - |> BDS.MapUtils.maybe_put( - :missing_translation_language, - BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"]) - ) - |> BDS.MapUtils.maybe_put(:year, arguments["year"]) - |> BDS.MapUtils.maybe_put(:month, arguments["month"]) - |> BDS.MapUtils.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 search_all_counted_posts(project_id, arguments) do - filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1) - {:ok, %{total: total}} = Search.search_posts(project_id, "", filters) - - filters = Map.put(filters, :limit, max(total, 1)) - {:ok, result} = Search.search_posts(project_id, "", filters) - - result - end - - defp search_result(project_id, query, filters, mapper) do - {:ok, result} = Search.search_posts(project_id, query, filters) - - %{ - posts: Enum.map(result.posts, mapper), - total: result.total, - offset: result.offset, - limit: result.limit, - has_more: result.offset + result.limit < result.total - } - end - defp with_post(arguments, project_id, not_found_result, success_fun) do case Repo.get_by(Post, id: arguments["postId"] || arguments["post_id"], diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 9d46a2e..441dcc4 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -31,26 +31,20 @@ defmodule BDS.Desktop.ShellLive do alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents alias BDS.Desktop.ShellLive.SidebarEvents - alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState alias BDS.Desktop.ShellLive.{ ChatSurface, + ContentState, Layout, SessionUtil, ShellCommandRunner, SidebarCreate, SocketState, TabHelpers, - TaskLocalization, TitlebarMenu, UrlState } - import TaskLocalization, - only: [ - localize_task_status: 2 - ] - import TabHelpers, only: [ tab_id_for_route: 2, @@ -728,79 +722,9 @@ defmodule BDS.Desktop.ShellLive do defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench) - defp refresh_content(socket, workbench) do - projects = - case ShellData.project_snapshot() do - {:ok, data} -> data - {:error, :not_ready} -> ShellData.default_project_snapshot() - end + defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench) - dashboard = - case ShellData.dashboard(projects.active_project_id) do - {:ok, data} -> data - {:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot() - end - - git_badge_count = - case ShellData.git_badge_count(projects.active_project_id) do - {:ok, count} -> count - {:error, :not_ready} -> 0 - end - - active_view_id = Atom.to_string(workbench.active_view) - - sidebar_data = - case ShellData.sidebar_view( - projects.active_project_id, - active_view_id, - ShellSidebarState.current_filters(socket, active_view_id) - ) do - {:ok, data} -> data - {:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{}) - end - - sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data) - - socket - |> assign(:projects, projects) - |> assign(:current_project, ShellData.current_project(projects)) - |> assign(:dashboard, dashboard) - |> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, [])) - |> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, [])) - |> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, [])) - |> assign( - :dashboard_tag_cloud_items, - ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, [])) - ) - |> assign(:git_badge_count, git_badge_count) - |> assign(:sidebar_data, sidebar_data) - |> refresh_layout(workbench) - end - - defp reload_shell(socket, workbench) do - tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{}) - raw_task_status = BDS.Tasks.status_snapshot() - page_language = socket.assigns[:page_language] || ShellData.ui_language() - - offline_mode = - if connected?(socket) do - Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true)) - else - Map.get(socket.assigns, :offline_mode, true) - end - - task_status = localize_task_status(raw_task_status, page_language) - - socket - |> assign(:tab_meta, tab_meta) - |> assign(:task_status, task_status) - |> assign(:offline_mode, offline_mode) - |> assign(:assistant_cards, ShellData.assistant_cards()) - |> assign(:supported_ui_languages, ShellData.supported_ui_languages()) - |> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups()) - |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) - |> refresh_content(workbench) - end + defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench) defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts) diff --git a/lib/bds/desktop/shell_live/content_state.ex b/lib/bds/desktop/shell_live/content_state.ex new file mode 100644 index 0000000..86e8cb1 --- /dev/null +++ b/lib/bds/desktop/shell_live/content_state.ex @@ -0,0 +1,83 @@ +defmodule BDS.Desktop.ShellLive.ContentState do + @moduledoc false + + import Phoenix.Component, only: [assign: 3] + + alias BDS.AI + alias BDS.Desktop.ShellData + alias BDS.Desktop.ShellLive.{SidebarState, SocketState, TabHelpers, TaskLocalization, TitlebarMenu} + + def refresh_content(socket, workbench) do + projects = + case ShellData.project_snapshot() do + {:ok, data} -> data + {:error, :not_ready} -> ShellData.default_project_snapshot() + end + + dashboard = + case ShellData.dashboard(projects.active_project_id) do + {:ok, data} -> data + {:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot() + end + + git_badge_count = + case ShellData.git_badge_count(projects.active_project_id) do + {:ok, count} -> count + {:error, :not_ready} -> 0 + end + + active_view_id = Atom.to_string(workbench.active_view) + + sidebar_data = + case ShellData.sidebar_view( + projects.active_project_id, + active_view_id, + SidebarState.current_filters(socket, active_view_id) + ) do + {:ok, data} -> data + {:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{}) + end + + sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data) + + socket + |> assign(:projects, projects) + |> assign(:current_project, ShellData.current_project(projects)) + |> assign(:dashboard, dashboard) + |> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, [])) + |> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, [])) + |> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, [])) + |> assign( + :dashboard_tag_cloud_items, + ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, [])) + ) + |> assign(:git_badge_count, git_badge_count) + |> assign(:sidebar_data, sidebar_data) + |> SocketState.refresh_layout(workbench) + end + + def reload_shell(socket, workbench) do + tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{}) + raw_task_status = BDS.Tasks.status_snapshot() + page_language = socket.assigns[:page_language] || ShellData.ui_language() + + offline_mode = + if Phoenix.LiveView.connected?(socket) do + Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true)) + else + Map.get(socket.assigns, :offline_mode, true) + end + + task_status = TaskLocalization.localize_task_status(raw_task_status, page_language) + + socket + |> assign(:tab_meta, tab_meta) + |> assign(:task_status, task_status) + |> assign(:offline_mode, offline_mode) + |> assign(:assistant_cards, ShellData.assistant_cards()) + |> assign(:supported_ui_languages, ShellData.supported_ui_languages()) + |> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups()) + |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index]) + |> refresh_content(workbench) + end +end diff --git a/test/bds/csm036_file_split_test.exs b/test/bds/csm036_file_split_test.exs new file mode 100644 index 0000000..c50a433 --- /dev/null +++ b/test/bds/csm036_file_split_test.exs @@ -0,0 +1,58 @@ +defmodule BDS.CSM036FileSplitTest do + use ExUnit.Case, async: false + + @shell_live_path "lib/bds/desktop/shell_live.ex" + @shell_live_content_state_path "lib/bds/desktop/shell_live/content_state.ex" + @chat_path "lib/bds/ai/chat.ex" + @chat_title_generator_path "lib/bds/ai/chat_title_generator.ex" + @chat_tools_path "lib/bds/ai/chat_tools.ex" + @chat_tool_schemas_path "lib/bds/ai/chat_tool_schemas.ex" + + describe "source-level: shell_live refresh orchestration split" do + test "shell_live delegates refresh_content and reload_shell to a helper module" do + shell_live_source = File.read!(@shell_live_path) + content_state_source = File.read!(@shell_live_content_state_path) + + refute shell_live_source =~ "defp refresh_content(socket, workbench) do" + refute shell_live_source =~ "defp reload_shell(socket, workbench) do" + + assert shell_live_source =~ "defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench)" + assert shell_live_source =~ "defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench)" + + assert content_state_source =~ "def refresh_content(socket, workbench) do" + assert content_state_source =~ "def reload_shell(socket, workbench) do" + end + end + + describe "source-level: ai chat title split" do + test "chat title generation logic lives in a dedicated helper module" do + chat_source = File.read!(@chat_path) + title_generator_source = File.read!(@chat_title_generator_path) + + refute chat_source =~ "defp generate_chat_title(user_content, opts)" + refute chat_source =~ "defp build_chat_title_request(user_content, model)" + refute chat_source =~ "defp sanitize_chat_title(title) when is_binary(title)" + + assert title_generator_source =~ "def generate_chat_title(user_content, opts)" + assert title_generator_source =~ "def build_chat_title_request(user_content, model)" + assert title_generator_source =~ "def sanitize_chat_title(title) when is_binary(title)" + end + end + + describe "source-level: ai chat tool schema split" do + test "chat tool JSON schema builders live in a dedicated helper module" do + chat_tools_source = File.read!(@chat_tools_path) + chat_tool_schemas_source = File.read!(@chat_tool_schemas_path) + + refute chat_tools_source =~ "defp tool_spec(name, description, parameters) do" + refute chat_tools_source =~ "defp render_chart_schema do" + refute chat_tools_source =~ "defp render_form_schema do" + refute chat_tools_source =~ "defp post_search_schema(require_query) do" + + assert chat_tool_schemas_source =~ "def tool_spec(name, description, parameters) do" + assert chat_tool_schemas_source =~ "def render_chart_schema do" + assert chat_tool_schemas_source =~ "def render_form_schema do" + assert chat_tool_schemas_source =~ "def post_search_schema(require_query) do" + end + end +end