fix: more refactorings for cleaning code

This commit is contained in:
2026-06-25 15:50:08 +02:00
parent 52809b39b1
commit 57bdcef990
8 changed files with 710 additions and 613 deletions

View File

@@ -9,6 +9,7 @@ defmodule BDS.AI.Chat do
alias BDS.AI.CatalogProvider alias BDS.AI.CatalogProvider
alias BDS.AI.ChatConversation alias BDS.AI.ChatConversation
alias BDS.AI.ChatMessage alias BDS.AI.ChatMessage
alias BDS.AI.ChatTitleGenerator
alias BDS.AI.ChatTools alias BDS.AI.ChatTools
alias BDS.AI.InFlight alias BDS.AI.InFlight
alias BDS.AI.OpenAICompatibleRuntime 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_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 @default_max_output_tokens 16_384
@title_max_output_tokens 256
@chat_title_max_length 30
@chat_max_tool_rounds 10 @chat_max_tool_rounds 10
@chat_await_timeout_margin_ms 5_000 @chat_await_timeout_margin_ms 5_000
@default_context_window 128_000 @default_context_window 128_000
@@ -34,7 +33,7 @@ defmodule BDS.AI.Chat do
def start_chat(attrs \\ %{}) when is_map(attrs) do def start_chat(attrs \\ %{}) when is_map(attrs) do
now = Persistence.now_ms() now = Persistence.now_ms()
model = MapUtils.attr(attrs, :model) 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{}
|> ChatConversation.changeset(%{ |> ChatConversation.changeset(%{
@@ -430,11 +429,11 @@ defmodule BDS.AI.Chat do
conversation = Repo.get!(ChatConversation, conversation_id) conversation = Repo.get!(ChatConversation, conversation_id)
cond do 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") Logger.debug("Chat title generation skipped reason=:no_user_messages")
{:ok, reply} {:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) -> not ChatTitleGenerator.generated_chat_title?(conversation.title, conversation.model) ->
Logger.debug( Logger.debug(
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}" "Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
) )
@@ -444,7 +443,7 @@ defmodule BDS.AI.Chat do
true -> true ->
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}") 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 != "" -> {:ok, title} when is_binary(title) and title != "" ->
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -465,87 +464,6 @@ defmodule BDS.AI.Chat do
end end
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( defp chat_round(
_conversation, _conversation,
_messages, _messages,
@@ -882,9 +800,6 @@ defmodule BDS.AI.Chat do
) )
end 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 defp load_chat_messages(conversation_id) do
Repo.all( Repo.all(
from message in ChatMessage, from message in ChatMessage,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -4,6 +4,8 @@ defmodule BDS.AI.ChatTools do
import Ecto.Query import Ecto.Query
alias BDS.AI.Chat alias BDS.AI.Chat
alias BDS.AI.ChatToolQueryHelpers, as: QueryHelpers
alias BDS.AI.ChatToolSchemas, as: Schemas
alias BDS.Media, as: MediaContext alias BDS.Media, as: MediaContext
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.MCP.Queries alias BDS.MCP.Queries
@@ -12,7 +14,6 @@ defmodule BDS.AI.ChatTools do
alias BDS.Posts.PostMedia alias BDS.Posts.PostMedia
alias BDS.Projects.Project alias BDS.Projects.Project
alias BDS.Repo alias BDS.Repo
alias BDS.Search
@spec execute(String.t(), map(), String.t() | nil) :: map() @spec execute(String.t(), map(), String.t() | nil) :: map()
def execute("blog_stats", _arguments, project_id) do def execute("blog_stats", _arguments, project_id) do
@@ -58,8 +59,14 @@ defmodule BDS.AI.ChatTools do
def execute("search_posts", arguments, project_id) do def execute("search_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
filters = search_filters(arguments) filters = QueryHelpers.search_filters(arguments)
search_result(project_id, arguments["query"] || "", filters, &Queries.post_summary/1)
QueryHelpers.search_result(
project_id,
arguments["query"] || "",
filters,
&Queries.post_summary/1
)
end end
def execute("read_post_by_slug", arguments, project_id) do 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 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 = QueryHelpers.normalize_limit(arguments["limit"])
offset = normalize_offset(arguments["offset"]) offset = QueryHelpers.normalize_offset(arguments["offset"])
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: 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 post
|> Queries.post_summary() |> Queries.post_summary()
|> Map.put("url", "/posts/#{post.slug}") |> Map.put("url", "/posts/#{post.slug}")
@@ -95,7 +102,7 @@ defmodule BDS.AI.ChatTools do
def execute("list_media", arguments, project_id) do def execute("list_media", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"]) limit = QueryHelpers.normalize_limit(arguments["limit"])
Repo.all( Repo.all(
from(media in Media, from(media in Media,
@@ -194,7 +201,7 @@ defmodule BDS.AI.ChatTools do
def execute("count_posts", arguments, project_id) do def execute("count_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1) 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 = groups =
result.posts result.posts
@@ -321,7 +328,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "blog_stats", name: "blog_stats",
spec: spec:
tool_spec("blog_stats", "Return aggregate blog statistics", %{ Schemas.tool_spec("blog_stats", "Return aggregate blog statistics", %{
"type" => "object", "type" => "object",
"properties" => %{} "properties" => %{}
}) })
@@ -329,7 +336,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "get_blog_stats", name: "get_blog_stats",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_blog_stats", "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.", "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" => %{}} %{"type" => "object", "properties" => %{}}
@@ -338,7 +345,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "check_term", name: "check_term",
spec: spec:
tool_spec( Schemas.tool_spec(
"check_term", "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.", "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", name: "search_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"search_posts", "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.", "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", name: "read_post",
spec: spec:
tool_spec( Schemas.tool_spec(
"read_post", "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.", "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", name: "read_post_by_slug",
spec: spec:
tool_spec( Schemas.tool_spec(
"read_post_by_slug", "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.", "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", name: "list_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_posts", "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.", "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", name: "get_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_media", "get_media",
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.", "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", name: "list_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_media", "list_media",
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.", "List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
limit_schema() Schemas.limit_schema()
) )
}, },
%{ %{
name: "view_image", name: "view_image",
spec: spec:
tool_spec( Schemas.tool_spec(
"view_image", "view_image",
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.", "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"]} "size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
}) })
) )
@@ -424,25 +431,25 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "update_post_metadata", name: "update_post_metadata",
spec: spec:
tool_spec( Schemas.tool_spec(
"update_post_metadata", "update_post_metadata",
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.", "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", name: "update_media_metadata",
spec: spec:
tool_spec( Schemas.tool_spec(
"update_media_metadata", "update_media_metadata",
"Update metadata for a media file: title, alt text, caption, or tags.", "Update metadata for a media file: title, alt text, caption, or tags.",
update_media_metadata_schema() Schemas.update_media_metadata_schema()
) )
}, },
%{ %{
name: "list_tags", name: "list_tags",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_tags", "list_tags",
"List all tags used across blog posts with post counts.", "List all tags used across blog posts with post counts.",
%{ %{
@@ -454,7 +461,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "list_categories", name: "list_categories",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_categories", "list_categories",
"List all categories used across blog posts with post counts.", "List all categories used across blog posts with post counts.",
%{"type" => "object", "properties" => %{}} %{"type" => "object", "properties" => %{}}
@@ -463,46 +470,46 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "count_posts", name: "count_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"count_posts", "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 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", name: "get_post_backlinks",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_backlinks", "get_post_backlinks",
"Get all posts that link to a specific post.", "Get all posts that link to a specific post.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_post_outlinks", name: "get_post_outlinks",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_outlinks", "get_post_outlinks",
"Get all posts that a specific post links to.", "Get all posts that a specific post links to.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_post_media", name: "get_post_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_media", "get_post_media",
"Get media files linked to a specific post.", "Get media files linked to a specific post.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_media_posts", name: "get_media_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_media_posts", "get_media_posts",
"Get posts that use a specific media file.", "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", name: "render_card",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_card", "render_card",
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.", "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", name: "render_table",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_table", "render_table",
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.", "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", name: "render_chart",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_chart", "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 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", name: "render_form",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_form", "render_form",
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.", "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", name: "render_metric",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_metric", "render_metric",
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.", "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", name: "render_list",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_list", "render_list",
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.", "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", name: "render_tabs",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_tabs", "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 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", name: "render_mindmap",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_mindmap", "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 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
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 defp with_post(arguments, project_id, not_found_result, success_fun) do
case Repo.get_by(Post, case Repo.get_by(Post,
id: arguments["postId"] || arguments["post_id"], id: arguments["postId"] || arguments["post_id"],

View File

@@ -31,26 +31,20 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Desktop.ShellLive.PostEditor alias BDS.Desktop.ShellLive.PostEditor
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
alias BDS.Desktop.ShellLive.SidebarEvents alias BDS.Desktop.ShellLive.SidebarEvents
alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState
alias BDS.Desktop.ShellLive.{ alias BDS.Desktop.ShellLive.{
ChatSurface, ChatSurface,
ContentState,
Layout, Layout,
SessionUtil, SessionUtil,
ShellCommandRunner, ShellCommandRunner,
SidebarCreate, SidebarCreate,
SocketState, SocketState,
TabHelpers, TabHelpers,
TaskLocalization,
TitlebarMenu, TitlebarMenu,
UrlState UrlState
} }
import TaskLocalization,
only: [
localize_task_status: 2
]
import TabHelpers, import TabHelpers,
only: [ only: [
tab_id_for_route: 2, 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_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
defp refresh_content(socket, workbench) do defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench)
projects =
case ShellData.project_snapshot() do
{:ok, data} -> data
{:error, :not_ready} -> ShellData.default_project_snapshot()
end
dashboard = defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench)
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 encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts) defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)

View File

@@ -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

View File

@@ -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