Compare commits
2 Commits
95088f2d42
...
5a464920de
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a464920de | |||
| 13a86e92bd |
28
CODESMELL.md
28
CODESMELL.md
@@ -464,7 +464,33 @@ Total: 2245 lines now live in focused submodules; the remaining 647 in `BDS.Gene
|
||||
| `Translations` | 279 | `publish_post_translation/2`, `list_post_translations/1`, `upsert_post_translation/3`, `delete_post_translation/1`, `sync_post_translation_from_file/1`, `rewrite_published_post_translation/1`, `publish_translation/2`, `publish_post_translations/1`, `normalize_translation_updates`, `maybe_reopen_source_post_for_manual_translation` |
|
||||
|
||||
Public API on `BDS.Posts` preserved via `defdelegate` for: `slug_available/3`, `unique_slug_for_title/3`, `validate_translations/2`, `fix_invalid_translations/1`, `rebuild_posts_from_files/2`, `import_orphan_post_file/2`, `import_orphan_post_translation_file/2`, `publish_post_translation/2`, `list_post_translations/1`, `upsert_post_translation/3`, `delete_post_translation/1`, `sync_post_translation_from_file/1`, `rewrite_published_post_translation/1`. Remaining clusters in posts.ex are core CRUD (`create_post`, `update_post`, `publish_post`, `delete_post`, `archive_post`, `discard_post_changes`, `sync_post_from_file`, `rewrite_published_post`, `editor_body`), small stats (`dashboard_stats`, `post_counts_by_year_month`, ~40 lines extractable), and `rebuild_post_links` (~22 lines). Stats could be split next, but ~569 lines is a reasonable steady state.
|
||||
- ⏳ `BDS.AI` (1711).
|
||||
- ⏳ `BDS.AI` (1711 → 168, **90% reduction**). Submodules extracted under `lib/bds/ai/`:
|
||||
|
||||
| Module | Lines | Responsibility |
|
||||
|---|---|---|
|
||||
| `Chat` | 597 | Public chat API (`start_chat`, `list_chat_conversations`, `available_chat_models`, `set_conversation_model`, `list_chat_messages`, `send_chat_message`, `cancel_chat`) + chat round/tool-call orchestration, request building, message truncation, system prompt + project stats summary, conversation/message persistence, `count_distinct_string_list/3`, `normalize_usage/1` |
|
||||
| `OneShot` | 382 | One-shot operations (`detect_language`, `analyze_taxonomy`, `analyze_import_taxonomy`, `analyze_post`, `translate_post`, `analyze_image`, `translate_media`) + per-op system/user prompt builders, JSON response extraction, post/media input normalization, taxonomy mapping filtering |
|
||||
| `Catalog` | 306 | Model catalog API (`list_endpoint_models`, `refresh_model_catalog`, `list_catalog_providers`, `get_catalog_model`, `catalog_meta`, `put_model_capabilities`, `format_model`, `model_capabilities`, `decode_nullable_json`) + models.dev fetch, persistence, modality parsing |
|
||||
| `ChatTools` | 271 | Chat tool dispatch (`execute/3`) for blog_stats, list_posts, list_media, render_table/chart/form/card/metric/list/tabs/mindmap; tool spec generation (`available_specs/2`) |
|
||||
| `Runtime` | 100 | `model_preference_keys/0`, `resolve_target/2`, `validate_target/3`, `endpoint_with_model/2`, per-operation airplane/online resolution |
|
||||
| `SettingsStore` | 78 | Setting/secret/catalog-meta storage helpers (`get_setting`, `put_setting`, `delete_setting`, `put_secret`, `get_secret`, `encrypted_key`, `secret_backend`, catalog meta) |
|
||||
|
||||
Public `BDS.AI` API preserved via `defdelegate` for all extracted operations. Remaining 168 lines hold endpoint storage (`put_endpoint`, `get_endpoint`, `delete_endpoint`), airplane mode (`set_airplane_mode`, `airplane_mode?`), model preferences (`put_model_preference`, `get_model_preference`), and the defdelegate facade.
|
||||
|
||||
- ⏳ `BDS.Scripting.Capabilities` (1715 → 194, **89% reduction**). Submodules extracted under `lib/bds/scripting/capabilities/`:
|
||||
|
||||
| Module | Lines | Responsibility |
|
||||
|---|---|---|
|
||||
| `Util` | 301 | Sanitization, normalization, arity wrappers, optional-key map builders, datetime parsing, project-path lookup, shell-open helpers |
|
||||
| `Posts` | 270 | All `posts.*` capabilities (CRUD, publishing, body/cover/excerpt, search, tags, categories, archive, restore, preview path, names-with-counts) |
|
||||
| `Media` | 254 | All `media.*` capabilities (CRUD, upload, thumbnails, metadata, translations, search) |
|
||||
| `Crud` | 284 | `scripts.*`, `templates.*`, `tags.*`, `tasks.*` CRUD/search/exec |
|
||||
| `Projects` | 204 | Project CRUD, metadata read/write, sync-meta-on-startup, data paths, project-for-folder |
|
||||
| `AppShell` | 134 | Clipboard, bookmarklet, title-bar metrics, renderer-ready, open/select folder, show-in-folder, trigger-menu-action, preview-target, test-mode/env detection |
|
||||
| `Bridges` | 176 | Sync availability, repo state/status/history/fetch/pull/push/commit-all, upload-site, AI detect/analyze/translate (post + media), embeddings progress/find-similar/compute-similarities/suggest-tags/find-duplicates/dismiss-pair/index-unindexed |
|
||||
|
||||
Public `BDS.Scripting.Capabilities.for_project/2` contract preserved unchanged. Main file (194 lines) now holds only the capability-map assembly using `import` of all submodules.
|
||||
|
||||
- ⏳ `BDS.MCP` (677).
|
||||
|
||||
---
|
||||
|
||||
1613
lib/bds/ai.ex
1613
lib/bds/ai.ex
File diff suppressed because it is too large
Load Diff
306
lib/bds/ai/catalog.ex
Normal file
306
lib/bds/ai/catalog.ex
Normal file
@@ -0,0 +1,306 @@
|
||||
defmodule BDS.AI.Catalog do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.AI.SettingsStore,
|
||||
only: [
|
||||
get_setting: 1,
|
||||
put_setting: 2,
|
||||
get_catalog_meta_value: 1,
|
||||
put_catalog_meta: 2
|
||||
]
|
||||
|
||||
alias BDS.AI.CatalogProvider
|
||||
alias BDS.AI.Model
|
||||
alias BDS.AI.ModelModality
|
||||
alias BDS.AI.OpenAICompatibleRuntime
|
||||
alias BDS.Persistence
|
||||
alias BDS.Repo
|
||||
|
||||
@catalog_url "https://models.dev/api.json"
|
||||
|
||||
@spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()}
|
||||
def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
||||
http_client = Keyword.get(opts, :http_client, Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient))
|
||||
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
|
||||
end
|
||||
|
||||
@spec refresh_model_catalog(keyword()) ::
|
||||
{:ok, %{success: boolean(), models_updated: non_neg_integer(), not_modified: boolean()}}
|
||||
| {:error, term()}
|
||||
def refresh_model_catalog(opts \\ []) when is_list(opts) do
|
||||
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
|
||||
|
||||
headers =
|
||||
%{"accept" => "application/json"}
|
||||
|> maybe_put_header("if-none-match", get_catalog_meta_value("etag"))
|
||||
|
||||
with {:ok, response} <- http_get(http_client, @catalog_url, headers) do
|
||||
case response.status do
|
||||
304 ->
|
||||
:ok = put_catalog_meta("last_fetched_at", DateTime.utc_now() |> DateTime.to_iso8601())
|
||||
{:ok, %{success: true, models_updated: 0, not_modified: true}}
|
||||
|
||||
200 ->
|
||||
payload = Jason.decode!(response.body)
|
||||
models_updated = persist_catalog(payload)
|
||||
|
||||
if etag = response.headers["etag"] do
|
||||
:ok = put_catalog_meta("etag", etag)
|
||||
end
|
||||
|
||||
:ok = put_catalog_meta("last_fetched_at", DateTime.utc_now() |> DateTime.to_iso8601())
|
||||
|
||||
{:ok, %{success: true, models_updated: models_updated, not_modified: false}}
|
||||
|
||||
status ->
|
||||
{:error, %{kind: :http_error, status: status}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_catalog_providers() :: [map()]
|
||||
def list_catalog_providers do
|
||||
Repo.all(from(provider in CatalogProvider, order_by: [asc: provider.id]))
|
||||
|> Enum.map(fn provider ->
|
||||
%{
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
env_keys: decode_json_list(provider.env_keys),
|
||||
package_ref: provider.package_ref,
|
||||
api_url: provider.api_url,
|
||||
doc_url: provider.doc_url,
|
||||
updated_at: provider.updated_at
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
@spec get_catalog_model(String.t(), String.t() | nil) :: {:ok, map()} | {:error, :not_found}
|
||||
def get_catalog_model(model_id, provider_id \\ nil) when is_binary(model_id) do
|
||||
query =
|
||||
from(model in Model,
|
||||
where: model.model_id == ^model_id,
|
||||
order_by: [asc: model.provider]
|
||||
)
|
||||
|
||||
query =
|
||||
case provider_id do
|
||||
nil -> query
|
||||
provider -> from(model in query, where: model.provider == ^provider)
|
||||
end
|
||||
|
||||
case Repo.one(query) do
|
||||
nil -> {:error, :not_found}
|
||||
model -> {:ok, format_model(model)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec catalog_meta(String.t()) :: {:ok, String.t() | nil}
|
||||
def catalog_meta(key) when is_binary(key) do
|
||||
{:ok, get_catalog_meta_value(key)}
|
||||
end
|
||||
|
||||
@spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()}
|
||||
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
|
||||
capabilities = %{
|
||||
supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")),
|
||||
supports_tool_calls: truthy?(Map.get(attrs, :supports_tool_calls) || Map.get(attrs, "supports_tool_calls"))
|
||||
}
|
||||
|
||||
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
|
||||
end
|
||||
|
||||
@spec format_model(map()) :: map()
|
||||
def format_model(model) do
|
||||
modalities =
|
||||
Repo.all(
|
||||
from(modality in ModelModality,
|
||||
where: modality.provider == ^model.provider and modality.model_id == ^model.model_id
|
||||
)
|
||||
)
|
||||
|
||||
%{
|
||||
provider: model.provider,
|
||||
model_id: model.model_id,
|
||||
name: model.name,
|
||||
family: model.family,
|
||||
supports_attachment: model.supports_attachment,
|
||||
supports_reasoning: model.supports_reasoning,
|
||||
supports_tool_calls: model.supports_tool_calls,
|
||||
supports_structured_output: model.supports_structured_output,
|
||||
supports_temperature: model.supports_temperature,
|
||||
knowledge: model.knowledge,
|
||||
release_date: model.release_date,
|
||||
last_updated_date: model.last_updated_date,
|
||||
open_weights: model.open_weights,
|
||||
input_price: model.input_price,
|
||||
output_price: model.output_price,
|
||||
cache_read_price: model.cache_read_price,
|
||||
cache_write_price: model.cache_write_price,
|
||||
context_window: model.context_window,
|
||||
max_input_tokens: model.max_input_tokens,
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
interleaved: model.interleaved,
|
||||
status: model.status,
|
||||
updated_at: model.updated_at,
|
||||
input_modalities:
|
||||
modalities
|
||||
|> Enum.filter(&(&1.direction == :input))
|
||||
|> Enum.map(&Atom.to_string(&1.modality)),
|
||||
output_modalities:
|
||||
modalities
|
||||
|> Enum.filter(&(&1.direction == :output))
|
||||
|> Enum.map(&Atom.to_string(&1.modality))
|
||||
}
|
||||
end
|
||||
|
||||
@spec model_capabilities(String.t()) :: %{supports_attachment: boolean(), supports_tool_calls: boolean()}
|
||||
def model_capabilities(model_id) do
|
||||
overrides = decode_model_capabilities_override(model_id)
|
||||
|
||||
from_catalog =
|
||||
case get_catalog_model(model_id) do
|
||||
{:ok, model} ->
|
||||
%{
|
||||
supports_attachment: model.supports_attachment or ("image" in model.input_modalities),
|
||||
supports_tool_calls: model.supports_tool_calls
|
||||
}
|
||||
|
||||
_other ->
|
||||
inferred_model_capabilities(model_id)
|
||||
end
|
||||
|
||||
Map.merge(from_catalog, overrides)
|
||||
end
|
||||
|
||||
@spec decode_nullable_json(nil | binary()) :: any()
|
||||
def decode_nullable_json(nil), do: nil
|
||||
def decode_nullable_json(value) when is_binary(value), do: Jason.decode!(value)
|
||||
|
||||
defp inferred_model_capabilities(model_id) do
|
||||
normalized = String.downcase(model_id)
|
||||
|
||||
%{
|
||||
supports_attachment:
|
||||
String.contains?(normalized, "4o") or String.contains?(normalized, "vision") or
|
||||
String.contains?(normalized, "llava"),
|
||||
supports_tool_calls:
|
||||
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
|
||||
String.contains?(normalized, "tool")
|
||||
}
|
||||
end
|
||||
|
||||
defp decode_model_capabilities_override(model_id) do
|
||||
case get_setting("ai.model_capabilities.#{model_id}") do
|
||||
nil -> %{}
|
||||
value -> Jason.decode!(value) |> atomize_map_keys()
|
||||
end
|
||||
end
|
||||
|
||||
defp atomize_map_keys(map) do
|
||||
Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), value} end)
|
||||
end
|
||||
|
||||
defp persist_catalog(payload) do
|
||||
now = Persistence.now_ms()
|
||||
|
||||
Repo.transaction(fn ->
|
||||
Repo.delete_all(ModelModality)
|
||||
Repo.delete_all(Model)
|
||||
Repo.delete_all(CatalogProvider)
|
||||
|
||||
Enum.reduce(payload, 0, fn {provider_id, provider_data}, count ->
|
||||
provider_attrs = %{
|
||||
id: provider_id,
|
||||
name: Map.get(provider_data, "name", provider_id),
|
||||
env_keys: Jason.encode!(Map.get(provider_data, "env", [])),
|
||||
package_ref: Map.get(provider_data, "npm"),
|
||||
api_url: Map.get(provider_data, "api"),
|
||||
doc_url: Map.get(provider_data, "doc"),
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
%CatalogProvider{}
|
||||
|> CatalogProvider.changeset(provider_attrs)
|
||||
|> Repo.insert!()
|
||||
|
||||
models = Map.get(provider_data, "models", %{})
|
||||
|
||||
Enum.reduce(models, count, fn {model_id, model_data}, inner_count ->
|
||||
model_attrs = %{
|
||||
provider: provider_id,
|
||||
model_id: model_id,
|
||||
name: Map.get(model_data, "name", model_id),
|
||||
family: Map.get(model_data, "family"),
|
||||
supports_attachment: Map.get(model_data, "attachment", false),
|
||||
supports_reasoning: Map.get(model_data, "reasoning", false),
|
||||
supports_tool_calls: Map.get(model_data, "tool_call", false),
|
||||
supports_structured_output: Map.get(model_data, "structured_output", false),
|
||||
supports_temperature: Map.get(model_data, "temperature", false),
|
||||
knowledge: Map.get(model_data, "knowledge"),
|
||||
release_date: Map.get(model_data, "release_date"),
|
||||
last_updated_date: Map.get(model_data, "last_updated"),
|
||||
open_weights: Map.get(model_data, "open_weights", false),
|
||||
input_price: get_in(model_data, ["cost", "input"]),
|
||||
output_price: get_in(model_data, ["cost", "output"]),
|
||||
cache_read_price: get_in(model_data, ["cost", "cache_read"]),
|
||||
cache_write_price: get_in(model_data, ["cost", "cache_write"]),
|
||||
context_window: get_in(model_data, ["limit", "context"]) || 0,
|
||||
max_input_tokens: get_in(model_data, ["limit", "input"]) || 0,
|
||||
max_output_tokens: get_in(model_data, ["limit", "output"]) || 0,
|
||||
interleaved: encode_nullable(Map.get(model_data, "interleaved")),
|
||||
status: Map.get(model_data, "status"),
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
%Model{}
|
||||
|> Model.changeset(model_attrs)
|
||||
|> Repo.insert!()
|
||||
|
||||
insert_modalities(provider_id, model_id, Map.get(model_data, "input_modalities", []), :input)
|
||||
insert_modalities(provider_id, model_id, Map.get(model_data, "output_modalities", []), :output)
|
||||
|
||||
inner_count + 1
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|> case do
|
||||
{:ok, count} -> count
|
||||
{:error, reason} -> raise reason
|
||||
end
|
||||
end
|
||||
|
||||
defp insert_modalities(provider_id, model_id, modalities, direction) do
|
||||
Enum.each(modalities, fn modality ->
|
||||
%ModelModality{}
|
||||
|> ModelModality.changeset(%{
|
||||
provider: provider_id,
|
||||
model_id: model_id,
|
||||
direction: direction,
|
||||
modality: parse_modality(modality)
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_modality("text"), do: :text
|
||||
defp parse_modality("image"), do: :image
|
||||
defp parse_modality("audio"), do: :audio
|
||||
defp parse_modality("file"), do: :file
|
||||
defp parse_modality("tool"), do: :tool
|
||||
defp parse_modality(other) when is_binary(other), do: String.to_atom(other)
|
||||
|
||||
defp encode_nullable(nil), do: nil
|
||||
defp encode_nullable(value), do: Jason.encode!(value)
|
||||
|
||||
defp http_get(client, url, headers) when is_atom(client), do: client.get(url, headers)
|
||||
defp http_get(client, url, headers) when is_function(client, 2), do: client.(url, headers)
|
||||
|
||||
defp maybe_put_header(headers, _key, nil), do: headers
|
||||
defp maybe_put_header(headers, key, value), do: Map.put(headers, key, value)
|
||||
|
||||
defp decode_json_list(nil), do: []
|
||||
defp decode_json_list(value), do: Jason.decode!(value)
|
||||
|
||||
defp truthy?(value), do: value in [true, "true", 1, "1"]
|
||||
end
|
||||
597
lib/bds/ai/chat.ex
Normal file
597
lib/bds/ai/chat.ex
Normal file
@@ -0,0 +1,597 @@
|
||||
defmodule BDS.AI.Chat do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.AI.Catalog
|
||||
alias BDS.AI.CatalogProvider
|
||||
alias BDS.AI.ChatConversation
|
||||
alias BDS.AI.ChatMessage
|
||||
alias BDS.AI.ChatTools
|
||||
alias BDS.AI.InFlight
|
||||
alias BDS.AI.OpenAICompatibleRuntime
|
||||
alias BDS.AI.Runtime
|
||||
alias BDS.AI.SecretBackend
|
||||
import BDS.AI.SettingsStore, only: [get_setting: 1]
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Persistence
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Projects.Project
|
||||
alias BDS.Repo
|
||||
|
||||
@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
|
||||
@chat_max_tool_rounds 10
|
||||
@default_context_window 128_000
|
||||
|
||||
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
||||
def start_chat(attrs \\ %{}) when is_map(attrs) do
|
||||
now = Persistence.now_ms()
|
||||
model = Map.get(attrs, :model) || Map.get(attrs, "model")
|
||||
title = Map.get(attrs, :title) || Map.get(attrs, "title") || generated_chat_title(model)
|
||||
|
||||
%ChatConversation{}
|
||||
|> ChatConversation.changeset(%{
|
||||
id: Ecto.UUID.generate(),
|
||||
title: title,
|
||||
model: model,
|
||||
copilot_session_id: Map.get(attrs, :copilot_session_id) || Map.get(attrs, "copilot_session_id"),
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
|> Repo.insert()
|
||||
|> case do
|
||||
{:ok, conversation} -> {:ok, format_conversation(conversation)}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_chat_conversations() :: [map()]
|
||||
def list_chat_conversations do
|
||||
Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at])
|
||||
|> Enum.map(&format_conversation/1)
|
||||
end
|
||||
|
||||
@spec available_chat_models(String.t() | nil) :: [map()]
|
||||
def available_chat_models(current_model \\ nil) do
|
||||
endpoint_models = configured_chat_models()
|
||||
|
||||
preference_models =
|
||||
[:chat, :airplane_chat]
|
||||
|> Enum.flat_map(fn key ->
|
||||
case AI.get_model_preference(key) do
|
||||
{:ok, model} when is_binary(model) and model != "" -> [model]
|
||||
_other -> []
|
||||
end
|
||||
end)
|
||||
|
||||
provider_names = catalog_provider_name_map()
|
||||
endpoint_provider_map = Map.new(endpoint_models, &{&1.id, &1.provider})
|
||||
|
||||
[current_model | Enum.map(endpoint_models, & &1.id) ++ preference_models]
|
||||
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(&build_available_chat_model(&1, endpoint_provider_map, provider_names))
|
||||
|> Enum.sort_by(fn model ->
|
||||
{
|
||||
String.downcase(to_string(model.provider_name || model.provider || "")),
|
||||
String.downcase(to_string(model.name || model.id))
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
@spec set_conversation_model(String.t(), String.t()) ::
|
||||
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def set_conversation_model(conversation_id, model_id)
|
||||
when is_binary(conversation_id) and is_binary(model_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
%ChatConversation{} = conversation ->
|
||||
conversation
|
||||
|> ChatConversation.changeset(%{model: model_id, updated_at: Persistence.now_ms()})
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, updated_conversation} -> {:ok, format_conversation(updated_conversation)}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_chat_messages(String.t()) :: [map()]
|
||||
def list_chat_messages(conversation_id) when is_binary(conversation_id) do
|
||||
Repo.all(
|
||||
from message in ChatMessage,
|
||||
where: message.conversation_id == ^conversation_id,
|
||||
order_by: [asc: message.created_at, asc: message.id]
|
||||
)
|
||||
|> Enum.map(&format_chat_message/1)
|
||||
end
|
||||
|
||||
@spec send_chat_message(String.t(), String.t(), keyword()) ::
|
||||
{:ok, map()} | {:error, :not_found | term()}
|
||||
def send_chat_message(conversation_id, content, opts \\ [])
|
||||
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
|
||||
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
|
||||
{:ok, user_message} <- persist_chat_message(%{
|
||||
conversation_id: conversation.id,
|
||||
role: :user,
|
||||
content: content,
|
||||
created_at: Persistence.now_ms()
|
||||
}) do
|
||||
task =
|
||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||
receive do
|
||||
:sandbox_ready -> :ok
|
||||
end
|
||||
|
||||
do_send_chat_message(conversation, user_message, opts)
|
||||
end)
|
||||
|
||||
InFlight.register(conversation.id, task.pid)
|
||||
:ok = allow_repo_sandbox(task.pid)
|
||||
send(task.pid, :sandbox_ready)
|
||||
|
||||
try do
|
||||
await_chat_task(task)
|
||||
after
|
||||
InFlight.unregister(conversation.id)
|
||||
end
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@spec cancel_chat(String.t()) :: :ok
|
||||
def cancel_chat(conversation_id) when is_binary(conversation_id) do
|
||||
case InFlight.lookup(conversation_id) do
|
||||
nil -> :ok
|
||||
pid ->
|
||||
_ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def count_distinct_string_list(schema, field, project_id) do
|
||||
Repo.all(from record in schema, where: field(record, :project_id) == ^project_id, select: field(record, ^field))
|
||||
|> List.flatten()
|
||||
|> Enum.reject(&blank?/1)
|
||||
|> MapSet.new()
|
||||
|> MapSet.size()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def normalize_usage(usage) when is_map(usage) do
|
||||
%{
|
||||
input_tokens: usage[:input_tokens] || usage["input_tokens"],
|
||||
output_tokens: usage[:output_tokens] || usage["output_tokens"],
|
||||
cache_read_tokens: usage[:cache_read_tokens] || usage["cache_read_tokens"],
|
||||
cache_write_tokens: usage[:cache_write_tokens] || usage["cache_write_tokens"]
|
||||
}
|
||||
end
|
||||
|
||||
def normalize_usage(_usage) do
|
||||
%{
|
||||
input_tokens: nil,
|
||||
output_tokens: nil,
|
||||
cache_read_tokens: nil,
|
||||
cache_write_tokens: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp format_conversation(conversation) do
|
||||
%{
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
model: conversation.model,
|
||||
copilot_session_id: conversation.copilot_session_id,
|
||||
created_at: conversation.created_at,
|
||||
updated_at: conversation.updated_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_chat_message(message) do
|
||||
%{
|
||||
id: message.id,
|
||||
conversation_id: message.conversation_id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
tool_call_id: message.tool_call_id,
|
||||
tool_calls: Catalog.decode_nullable_json(message.tool_calls),
|
||||
token_usage_input: message.token_usage_input,
|
||||
token_usage_output: message.token_usage_output,
|
||||
cache_read_tokens: message.cache_read_tokens,
|
||||
cache_write_tokens: message.cache_write_tokens,
|
||||
created_at: message.created_at
|
||||
}
|
||||
end
|
||||
|
||||
defp configured_chat_models do
|
||||
[:online, :airplane]
|
||||
|> Enum.flat_map(fn kind ->
|
||||
case AI.get_endpoint(kind) do
|
||||
{:ok, %{model: model, url: url}} when is_binary(model) and model != "" ->
|
||||
[%{id: model, provider: infer_endpoint_provider(kind, url)}]
|
||||
|
||||
_other ->
|
||||
[]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_available_chat_model(model_id, endpoint_provider_map, provider_names) do
|
||||
case Catalog.get_catalog_model(model_id) do
|
||||
{:ok, model} ->
|
||||
provider = model.provider || Map.get(endpoint_provider_map, model_id, "other")
|
||||
|
||||
%{
|
||||
id: model.model_id,
|
||||
name: model.name || model.model_id,
|
||||
provider: provider,
|
||||
provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)),
|
||||
context_window: model.context_window,
|
||||
max_output_tokens: model.max_output_tokens
|
||||
}
|
||||
|
||||
{:error, :not_found} ->
|
||||
provider = Map.get(endpoint_provider_map, model_id, "other")
|
||||
|
||||
%{
|
||||
id: model_id,
|
||||
name: model_id,
|
||||
provider: provider,
|
||||
provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)),
|
||||
context_window: nil,
|
||||
max_output_tokens: nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp catalog_provider_name_map do
|
||||
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp infer_endpoint_provider(:online, _url), do: "generic-openai"
|
||||
|
||||
defp infer_endpoint_provider(:airplane, url) when is_binary(url) do
|
||||
normalized_url = String.downcase(url)
|
||||
|
||||
cond do
|
||||
String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") -> "ollama"
|
||||
String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") -> "lmstudio"
|
||||
true -> "generic-openai"
|
||||
end
|
||||
end
|
||||
|
||||
defp infer_endpoint_provider(:airplane, _url), do: "generic-openai"
|
||||
|
||||
defp fallback_provider_name("generic-openai"), do: "Generic OpenAI"
|
||||
defp fallback_provider_name("lmstudio"), do: "LM Studio"
|
||||
defp fallback_provider_name("mistral"), do: "Mistral"
|
||||
defp fallback_provider_name("ollama"), do: "Ollama"
|
||||
defp fallback_provider_name("openai"), do: "OpenAI"
|
||||
|
||||
defp fallback_provider_name(provider) when is_binary(provider) and provider != "" do
|
||||
provider
|
||||
|> String.split(["-", "_"], trim: true)
|
||||
|> Enum.map(&String.capitalize/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
defp fallback_provider_name(_provider), do: "Other"
|
||||
|
||||
defp do_send_chat_message(conversation, _user_message, opts) do
|
||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||
project_id = Keyword.get(opts, :project_id, active_project_id())
|
||||
|
||||
with {:ok, endpoint, model, mode} <-
|
||||
Runtime.resolve_target(
|
||||
:chat,
|
||||
conversation: conversation,
|
||||
secret_backend: Keyword.get(opts, :secret_backend, SecretBackend)
|
||||
),
|
||||
:ok <- Runtime.validate_target(:chat, model, mode),
|
||||
messages <- load_chat_messages(conversation.id),
|
||||
tools <- available_chat_tools(project_id, model),
|
||||
{:ok, reply} <- chat_round(conversation, messages, endpoint, model, project_id, tools, runtime, opts, @chat_max_tool_rounds) do
|
||||
{:ok, reply}
|
||||
end
|
||||
end
|
||||
|
||||
defp chat_round(_conversation, _messages, _endpoint, _model, _project_id, _tools, _runtime, _opts, 0) do
|
||||
{:error, %{kind: :tool_loop_exhausted}}
|
||||
end
|
||||
|
||||
defp chat_round(conversation, messages, endpoint, model, project_id, tools, runtime, opts, rounds_left) do
|
||||
request = build_chat_request(conversation, messages, model, project_id, tools)
|
||||
|
||||
with {:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
||||
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
|
||||
:ok <- touch_conversation(conversation.id) do
|
||||
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
|
||||
notify_chat_event(opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)})
|
||||
end
|
||||
|
||||
tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
|
||||
|
||||
Enum.each(tool_calls, fn tool_call ->
|
||||
notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call})
|
||||
end)
|
||||
|
||||
cond do
|
||||
tool_calls != [] and tools != [] ->
|
||||
with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id, opts),
|
||||
updated_messages <- load_chat_messages(conversation.id),
|
||||
{:ok, reply} <-
|
||||
chat_round(
|
||||
Repo.get!(ChatConversation, conversation.id),
|
||||
updated_messages,
|
||||
endpoint,
|
||||
model,
|
||||
project_id,
|
||||
tools,
|
||||
runtime,
|
||||
opts,
|
||||
rounds_left - 1
|
||||
) do
|
||||
{:ok, Map.put(reply, :tool_messages, tool_messages)}
|
||||
end
|
||||
|
||||
true ->
|
||||
{:ok,
|
||||
%{
|
||||
conversation: format_conversation(Repo.get!(ChatConversation, conversation.id)),
|
||||
assistant_message: format_chat_message(assistant_message),
|
||||
tool_messages: []
|
||||
}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp persist_assistant_response(conversation_id, response) do
|
||||
usage = normalize_usage(response.usage)
|
||||
|
||||
content =
|
||||
case Map.get(response, :content) do
|
||||
nil -> encode_nullable(Map.get(response, :json))
|
||||
value -> value
|
||||
end
|
||||
|
||||
persist_chat_message(%{
|
||||
conversation_id: conversation_id,
|
||||
role: :assistant,
|
||||
content: content,
|
||||
tool_calls: encode_nullable(Map.get(response, :tool_calls)),
|
||||
token_usage_input: usage.input_tokens,
|
||||
token_usage_output: usage.output_tokens,
|
||||
cache_read_tokens: usage.cache_read_tokens,
|
||||
cache_write_tokens: usage.cache_write_tokens,
|
||||
created_at: Persistence.now_ms()
|
||||
})
|
||||
end
|
||||
|
||||
defp execute_tool_calls(conversation_id, tool_calls, project_id, opts) do
|
||||
tool_messages =
|
||||
Enum.map(tool_calls, fn tool_call ->
|
||||
result = ChatTools.execute(tool_call.name, tool_call.arguments || %{}, project_id)
|
||||
|
||||
{:ok, message} =
|
||||
persist_chat_message(%{
|
||||
conversation_id: conversation_id,
|
||||
role: :tool,
|
||||
content: Jason.encode!(result),
|
||||
tool_call_id: tool_call.id,
|
||||
created_at: Persistence.now_ms()
|
||||
})
|
||||
|
||||
notify_chat_event(opts, {:chat_tool_result, conversation_id, tool_call.name})
|
||||
|
||||
format_chat_message(message)
|
||||
end)
|
||||
|
||||
{:ok, tool_messages}
|
||||
end
|
||||
|
||||
defp build_chat_request(conversation, messages, model, project_id, tools) do
|
||||
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id)}
|
||||
|
||||
%{
|
||||
operation: :chat,
|
||||
conversation_id: conversation.id,
|
||||
model: model,
|
||||
max_output_tokens: @default_max_output_tokens,
|
||||
tools: Enum.map(tools, & &1.spec),
|
||||
messages:
|
||||
[system_message | Enum.map(messages, &message_for_runtime/1)]
|
||||
|> truncate_chat_messages(model, tools)
|
||||
}
|
||||
end
|
||||
|
||||
defp message_for_runtime(%ChatMessage{} = message) do
|
||||
base = %{"role" => Atom.to_string(message.role)}
|
||||
|
||||
base = if is_binary(message.content), do: Map.put(base, "content", message.content), else: base
|
||||
base = if is_binary(message.tool_call_id), do: Map.put(base, "tool_call_id", message.tool_call_id), else: base
|
||||
|
||||
case Catalog.decode_nullable_json(message.tool_calls) do
|
||||
nil -> base
|
||||
tool_calls -> Map.put(base, "tool_calls", tool_calls)
|
||||
end
|
||||
end
|
||||
|
||||
defp truncate_chat_messages(messages, model, tools) do
|
||||
context_window = model_context_window(model)
|
||||
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))
|
||||
tool_budget = length(tools) * 120
|
||||
max_budget = max(context_window - reserve - tool_budget, 512)
|
||||
|
||||
[system | remainder] = messages
|
||||
|
||||
{kept, _tokens} =
|
||||
Enum.reduce(Enum.reverse(remainder), {[], approximate_message_tokens(system)}, fn message, {acc, used} ->
|
||||
message_tokens = approximate_message_tokens(message)
|
||||
|
||||
if used + message_tokens <= max_budget do
|
||||
{[message | acc], used + message_tokens}
|
||||
else
|
||||
{acc, used}
|
||||
end
|
||||
end)
|
||||
|
||||
[system | kept]
|
||||
end
|
||||
|
||||
defp available_chat_tools(project_id, model) do
|
||||
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
|
||||
end
|
||||
|
||||
defp chat_system_prompt(project_id) do
|
||||
base = get_setting("ai.system_prompt") || @default_system_prompt
|
||||
|
||||
case project_stats_summary(project_id) do
|
||||
nil -> base
|
||||
summary -> base <> "\n\nCurrent blog statistics:\n" <> summary
|
||||
end
|
||||
end
|
||||
|
||||
defp project_stats_summary(nil), do: nil
|
||||
|
||||
defp project_stats_summary(project_id) do
|
||||
post_count = Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id)
|
||||
media_count = Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id)
|
||||
tag_count = count_distinct_string_list(Post, :tags, project_id)
|
||||
category_count = count_distinct_string_list(Post, :categories, project_id)
|
||||
|
||||
Enum.join(
|
||||
[
|
||||
"Posts: #{post_count}",
|
||||
"Media: #{media_count}",
|
||||
"Tags: #{tag_count}",
|
||||
"Categories: #{category_count}"
|
||||
],
|
||||
"\n"
|
||||
)
|
||||
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,
|
||||
where: message.conversation_id == ^conversation_id,
|
||||
order_by: [asc: message.created_at, asc: message.id]
|
||||
)
|
||||
end
|
||||
|
||||
defp persist_chat_message(attrs) do
|
||||
%ChatMessage{}
|
||||
|> ChatMessage.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
defp touch_conversation(conversation_id) do
|
||||
now = Persistence.now_ms()
|
||||
|
||||
Repo.update_all(
|
||||
from(conversation in ChatConversation, where: conversation.id == ^conversation_id),
|
||||
set: [updated_at: now]
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp await_chat_task(task) do
|
||||
ref = task.ref
|
||||
|
||||
receive do
|
||||
{^ref, result} ->
|
||||
Process.demonitor(task.ref, [:flush])
|
||||
result
|
||||
|
||||
{:DOWN, ^ref, :process, _pid, reason} ->
|
||||
case reason do
|
||||
:normal ->
|
||||
receive do
|
||||
{^ref, result} -> result
|
||||
after
|
||||
10 -> {:error, :cancelled}
|
||||
end
|
||||
|
||||
:shutdown -> {:error, :cancelled}
|
||||
{:shutdown, _detail} -> {:error, :cancelled}
|
||||
_other -> {:error, :cancelled}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_tool_calls(nil), do: []
|
||||
|
||||
defp decode_tool_calls(tool_calls) when is_list(tool_calls) do
|
||||
Enum.map(tool_calls, fn tool_call ->
|
||||
%{
|
||||
id: tool_call[:id] || tool_call["id"],
|
||||
name: tool_call[:name] || tool_call["name"],
|
||||
arguments: tool_call[:arguments] || tool_call["arguments"] || %{}
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp approximate_message_tokens(message) when is_map(message) do
|
||||
message
|
||||
|> Map.values()
|
||||
|> Enum.map(&approximate_value_tokens/1)
|
||||
|> Enum.sum()
|
||||
|> Kernel.+(4)
|
||||
end
|
||||
|
||||
defp approximate_value_tokens(value) when is_binary(value), do: div(String.length(value), 4) + 1
|
||||
defp approximate_value_tokens(value) when is_list(value), do: Enum.map(value, &approximate_value_tokens/1) |> Enum.sum()
|
||||
defp approximate_value_tokens(value) when is_map(value), do: Jason.encode!(value) |> approximate_value_tokens()
|
||||
defp approximate_value_tokens(_value), do: 1
|
||||
|
||||
defp model_context_window(model_id) do
|
||||
case Catalog.get_catalog_model(model_id) do
|
||||
{:ok, model} when is_integer(model.context_window) and model.context_window > 0 -> model.context_window
|
||||
_other -> @default_context_window
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_chat_event(opts, event) do
|
||||
case Keyword.get(opts, :event_target) do
|
||||
pid when is_pid(pid) -> send(pid, event)
|
||||
callback when is_function(callback, 1) -> callback.(event)
|
||||
_other -> :ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp active_project_id do
|
||||
Repo.one(from project in Project, where: project.is_active == true, select: project.id)
|
||||
end
|
||||
|
||||
defp allow_repo_sandbox(pid) when is_pid(pid) do
|
||||
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
|
||||
try do
|
||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||
rescue
|
||||
_error -> :ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp encode_nullable(nil), do: nil
|
||||
defp encode_nullable(value), do: Jason.encode!(value)
|
||||
|
||||
defp blank?(value), do: value in [nil, ""]
|
||||
end
|
||||
271
lib/bds/ai/chat_tools.ex
Normal file
271
lib/bds/ai/chat_tools.ex
Normal file
@@ -0,0 +1,271 @@
|
||||
defmodule BDS.AI.ChatTools do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.AI.Chat
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Projects.Project
|
||||
alias BDS.Repo
|
||||
|
||||
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
||||
def execute("blog_stats", _arguments, project_id) do
|
||||
project_id = project_id || active_project_id()
|
||||
|
||||
%{
|
||||
post_count: Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id),
|
||||
media_count: Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id),
|
||||
tag_count: Chat.count_distinct_string_list(Post, :tags, project_id),
|
||||
category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
|
||||
}
|
||||
end
|
||||
|
||||
def execute("list_posts", arguments, project_id) do
|
||||
limit = normalize_limit(arguments["limit"])
|
||||
|
||||
Repo.all(
|
||||
from(post in Post,
|
||||
where: post.project_id == ^project_id,
|
||||
order_by: [desc: post.updated_at],
|
||||
limit: ^limit,
|
||||
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def execute("list_media", arguments, project_id) do
|
||||
limit = normalize_limit(arguments["limit"])
|
||||
|
||||
Repo.all(
|
||||
from(media in Media,
|
||||
where: media.project_id == ^project_id,
|
||||
order_by: [desc: media.updated_at],
|
||||
limit: ^limit,
|
||||
select: %{
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
mime_type: media.mime_type,
|
||||
filename: media.filename
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def execute("render_table", arguments, _project_id) do
|
||||
%{
|
||||
type: "table",
|
||||
title: arguments["title"],
|
||||
columns: arguments["columns"] || [],
|
||||
rows: arguments["rows"] || []
|
||||
}
|
||||
end
|
||||
|
||||
def execute("render_chart", arguments, _project_id) do
|
||||
%{
|
||||
type: "chart",
|
||||
title: arguments["title"],
|
||||
chart_type: arguments["chart_type"] || "bar",
|
||||
series: arguments["series"] || []
|
||||
}
|
||||
end
|
||||
|
||||
def execute("render_form", arguments, _project_id) do
|
||||
%{
|
||||
type: "form",
|
||||
title: arguments["title"],
|
||||
fields: arguments["fields"] || [],
|
||||
submit_label: arguments["submit_label"] || arguments["submitLabel"],
|
||||
submit_action: arguments["submit_action"] || arguments["submitAction"]
|
||||
}
|
||||
end
|
||||
|
||||
def execute("render_card", arguments, _project_id) do
|
||||
%{
|
||||
type: "card",
|
||||
title: arguments["title"],
|
||||
subtitle: arguments["subtitle"],
|
||||
body: arguments["body"],
|
||||
actions: arguments["actions"] || []
|
||||
}
|
||||
end
|
||||
|
||||
def execute("render_metric", arguments, _project_id) do
|
||||
%{
|
||||
type: "metric",
|
||||
label: arguments["label"],
|
||||
value: arguments["value"]
|
||||
}
|
||||
end
|
||||
|
||||
def execute("render_list", arguments, _project_id) do
|
||||
%{
|
||||
type: "list",
|
||||
title: arguments["title"],
|
||||
items: arguments["items"] || []
|
||||
}
|
||||
end
|
||||
|
||||
def execute("render_tabs", arguments, _project_id) do
|
||||
%{
|
||||
type: "tabs",
|
||||
title: arguments["title"],
|
||||
tabs: arguments["tabs"] || []
|
||||
}
|
||||
end
|
||||
|
||||
def execute("render_mindmap", arguments, _project_id) do
|
||||
%{
|
||||
type: "mindmap",
|
||||
title: arguments["title"],
|
||||
nodes: arguments["nodes"] || []
|
||||
}
|
||||
end
|
||||
|
||||
def execute(name, _arguments, _project_id) do
|
||||
%{error: "unknown_tool", name: name}
|
||||
end
|
||||
|
||||
@spec available_specs(String.t() | nil, map()) :: [map()]
|
||||
def available_specs(project_id, capabilities) do
|
||||
if capabilities.supports_tool_calls do
|
||||
project_tools =
|
||||
if is_binary(project_id) do
|
||||
[
|
||||
%{name: "blog_stats", spec: tool_spec("blog_stats", "Return aggregate blog statistics", %{"type" => "object", "properties" => %{}})},
|
||||
%{name: "list_posts", spec: tool_spec("list_posts", "List recent posts in the active project", limit_schema())},
|
||||
%{name: "list_media", spec: tool_spec("list_media", "List recent media items in the active project", limit_schema())}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
project_tools ++
|
||||
[
|
||||
%{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())},
|
||||
%{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())},
|
||||
%{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())},
|
||||
%{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())},
|
||||
%{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())},
|
||||
%{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())},
|
||||
%{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())},
|
||||
%{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())}
|
||||
]
|
||||
else
|
||||
[]
|
||||
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 render_table_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"columns" => %{"type" => "array"},
|
||||
"rows" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_chart_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"chart_type" => %{"type" => "string"},
|
||||
"series" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_form_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"fields" => %{"type" => "array"},
|
||||
"submitLabel" => %{"type" => "string"},
|
||||
"submitAction" => %{"type" => "string"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_card_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"subtitle" => %{"type" => "string"},
|
||||
"body" => %{"type" => "string"},
|
||||
"actions" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_metric_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string"},
|
||||
"value" => %{"type" => "string"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_list_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"items" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_tabs_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"tabs" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_mindmap_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"nodes" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
||||
defp normalize_limit(_value), do: 10
|
||||
|
||||
defp active_project_id do
|
||||
Repo.one(from(project in Project, where: project.is_active == true, select: project.id))
|
||||
end
|
||||
end
|
||||
382
lib/bds/ai/one_shot.ex
Normal file
382
lib/bds/ai/one_shot.ex
Normal file
@@ -0,0 +1,382 @@
|
||||
defmodule BDS.AI.OneShot do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.AI.Chat
|
||||
alias BDS.AI.OpenAICompatibleRuntime
|
||||
alias BDS.AI.Runtime
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Repo
|
||||
|
||||
@default_max_output_tokens 16_384
|
||||
|
||||
@spec detect_language(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def detect_language(text, opts \\ []) when is_binary(text) and is_list(opts) do
|
||||
run_one_shot(
|
||||
:detect_language,
|
||||
%{text: text},
|
||||
opts,
|
||||
fn json, usage ->
|
||||
{:ok, %{language_code: json["language_code"], usage: usage}}
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
@spec analyze_taxonomy(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do
|
||||
with {:ok, post} <- normalize_post_input(post_input) do
|
||||
run_one_shot(
|
||||
:analyze_taxonomy,
|
||||
post,
|
||||
opts,
|
||||
fn json, usage ->
|
||||
{:ok,
|
||||
%{
|
||||
tags: json["tags"] || [],
|
||||
categories: json["categories"] || [],
|
||||
usage: usage
|
||||
}}
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@spec analyze_import_taxonomy(map(), map(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
|
||||
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
|
||||
payload = %{
|
||||
import_categories: normalize_string_list(Map.get(import_terms, :categories) || Map.get(import_terms, "categories")),
|
||||
import_tags: normalize_string_list(Map.get(import_terms, :tags) || Map.get(import_terms, "tags")),
|
||||
existing_categories: normalize_string_list(Map.get(existing_terms, :categories) || Map.get(existing_terms, "categories")),
|
||||
existing_tags: normalize_string_list(Map.get(existing_terms, :tags) || Map.get(existing_terms, "tags"))
|
||||
}
|
||||
|
||||
run_one_shot(
|
||||
:import_taxonomy_mapping,
|
||||
payload,
|
||||
opts,
|
||||
fn json, usage ->
|
||||
{:ok,
|
||||
%{
|
||||
category_mappings:
|
||||
filter_taxonomy_mapping_response(
|
||||
json["categoryMappings"] || json["category_mappings"],
|
||||
payload.import_categories,
|
||||
payload.existing_categories
|
||||
),
|
||||
tag_mappings:
|
||||
filter_taxonomy_mapping_response(
|
||||
json["tagMappings"] || json["tag_mappings"],
|
||||
payload.import_tags,
|
||||
payload.existing_tags
|
||||
),
|
||||
usage: usage
|
||||
}}
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
@spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def analyze_post(post_input, opts \\ []) when is_list(opts) do
|
||||
with {:ok, post} <- normalize_post_input(post_input) do
|
||||
run_one_shot(
|
||||
:analyze_post,
|
||||
post,
|
||||
opts,
|
||||
fn json, usage ->
|
||||
{:ok,
|
||||
%{
|
||||
title: json["title"],
|
||||
excerpt: json["excerpt"],
|
||||
slug: json["slug"],
|
||||
usage: usage
|
||||
}}
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def translate_post(post_input, target_language, opts \\ [])
|
||||
when is_binary(target_language) and is_list(opts) do
|
||||
with {:ok, post} <- normalize_post_input(post_input) do
|
||||
run_one_shot(
|
||||
:translate_post,
|
||||
Map.put(post, :target_language, target_language),
|
||||
opts,
|
||||
fn json, usage ->
|
||||
{:ok,
|
||||
%{
|
||||
title: json["title"],
|
||||
excerpt: json["excerpt"],
|
||||
content: json["content"],
|
||||
usage: usage
|
||||
}}
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def analyze_image(media_input, opts \\ []) when is_list(opts) do
|
||||
with {:ok, media} <- normalize_media_input(media_input),
|
||||
:ok <- ensure_image_media(media) do
|
||||
run_one_shot(
|
||||
:analyze_image,
|
||||
media,
|
||||
opts,
|
||||
fn json, usage ->
|
||||
{:ok,
|
||||
%{
|
||||
title: json["title"],
|
||||
alt: json["alt"],
|
||||
caption: json["caption"],
|
||||
usage: usage
|
||||
}}
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def translate_media(media_input, target_language, opts \\ [])
|
||||
when is_binary(target_language) and is_list(opts) do
|
||||
with {:ok, media} <- normalize_media_input(media_input) do
|
||||
run_one_shot(
|
||||
:translate_media,
|
||||
Map.put(media, :target_language, target_language),
|
||||
opts,
|
||||
fn json, usage ->
|
||||
{:ok,
|
||||
%{
|
||||
title: json["title"],
|
||||
alt: json["alt"],
|
||||
caption: json["caption"],
|
||||
usage: usage
|
||||
}}
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp run_one_shot(operation, payload, opts, formatter) do
|
||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||
|
||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
|
||||
:ok <- Runtime.validate_target(operation, model, mode),
|
||||
request <- build_one_shot_request(operation, payload, model),
|
||||
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
||||
{:ok, json} <- extract_json_response(response),
|
||||
usage <- Chat.normalize_usage(response.usage),
|
||||
{:ok, result} <- formatter.(json, usage) do
|
||||
{:ok, result}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_one_shot_request(operation, payload, model) do
|
||||
%{
|
||||
operation: operation,
|
||||
model: model,
|
||||
max_output_tokens: @default_max_output_tokens,
|
||||
messages: [
|
||||
%{"role" => "system", "content" => one_shot_system_prompt(operation)},
|
||||
%{"role" => "user", "content" => one_shot_user_content(operation, payload)}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
defp one_shot_system_prompt(:detect_language) do
|
||||
"Return JSON with exactly one key: language_code."
|
||||
end
|
||||
|
||||
defp one_shot_system_prompt(:analyze_taxonomy) do
|
||||
"Return JSON with keys tags and categories, each an array of short strings."
|
||||
end
|
||||
|
||||
defp one_shot_system_prompt(:import_taxonomy_mapping) do
|
||||
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
|
||||
end
|
||||
|
||||
defp one_shot_system_prompt(:analyze_post) do
|
||||
"Return JSON with keys title, excerpt, and slug."
|
||||
end
|
||||
|
||||
defp one_shot_system_prompt(:translate_post) do
|
||||
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
|
||||
end
|
||||
|
||||
defp one_shot_system_prompt(:analyze_image) do
|
||||
"Return JSON with keys title, alt, and caption for the provided image."
|
||||
end
|
||||
|
||||
defp one_shot_system_prompt(:translate_media) do
|
||||
"Return JSON with keys title, alt, and caption translated to the requested language."
|
||||
end
|
||||
|
||||
defp one_shot_user_content(:detect_language, %{text: text}) do
|
||||
"Detect the language of this text: #{text}"
|
||||
end
|
||||
|
||||
defp one_shot_user_content(:analyze_taxonomy, post) do
|
||||
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||
end
|
||||
|
||||
defp one_shot_user_content(:import_taxonomy_mapping, payload) do
|
||||
[
|
||||
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
|
||||
"",
|
||||
"Imported categories:",
|
||||
Enum.join(payload.import_categories, ", "),
|
||||
"",
|
||||
"Imported tags:",
|
||||
Enum.join(payload.import_tags, ", "),
|
||||
"",
|
||||
"Existing project categories:",
|
||||
Enum.join(payload.existing_categories, ", "),
|
||||
"",
|
||||
"Existing project tags:",
|
||||
Enum.join(payload.existing_tags, ", "),
|
||||
"",
|
||||
"Return JSON only."
|
||||
]
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp one_shot_user_content(:analyze_post, post) do
|
||||
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||
end
|
||||
|
||||
defp one_shot_user_content(:translate_post, post) do
|
||||
"Translate this post to #{post.target_language}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
|
||||
end
|
||||
|
||||
defp one_shot_user_content(:analyze_image, media) do
|
||||
[
|
||||
%{"type" => "text", "text" => "Analyze this image and return title, alt text, and caption."},
|
||||
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
|
||||
]
|
||||
end
|
||||
|
||||
defp one_shot_user_content(:translate_media, media) do
|
||||
"Translate this media metadata to #{media.target_language}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
|
||||
end
|
||||
|
||||
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
|
||||
|
||||
defp extract_json_response(%{content: content}) when is_binary(content) do
|
||||
case Jason.decode(content) do
|
||||
{:ok, json} when is_map(json) -> {:ok, json}
|
||||
_other -> {:error, %{kind: :invalid_json_response}}
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_json_response(_response), do: {:error, %{kind: :invalid_json_response}}
|
||||
|
||||
defp normalize_post_input(%Post{} = post) do
|
||||
{:ok, %{title: post.title || "", excerpt: post.excerpt || "", content: post.content || ""}}
|
||||
end
|
||||
|
||||
defp normalize_post_input(post_id) when is_binary(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil -> {:error, :not_found}
|
||||
post -> normalize_post_input(post)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_post_input(attrs) when is_map(attrs) do
|
||||
{:ok,
|
||||
%{
|
||||
title: Map.get(attrs, :title) || Map.get(attrs, "title") || "",
|
||||
excerpt: Map.get(attrs, :excerpt) || Map.get(attrs, "excerpt") || "",
|
||||
content: Map.get(attrs, :content) || Map.get(attrs, "content") || ""
|
||||
}}
|
||||
end
|
||||
|
||||
defp normalize_media_input(%Media{} = media) do
|
||||
{:ok,
|
||||
%{
|
||||
mime_type: media.mime_type,
|
||||
title: media.title || "",
|
||||
alt: media.alt || "",
|
||||
caption: media.caption || "",
|
||||
image_url: Map.get(media, :image_url) || media_path_to_file_url(media.file_path)
|
||||
}}
|
||||
end
|
||||
|
||||
defp normalize_media_input(media_id) when is_binary(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil -> {:error, :not_found}
|
||||
media -> normalize_media_input(media)
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_media_input(attrs) when is_map(attrs) do
|
||||
{:ok,
|
||||
%{
|
||||
mime_type: Map.get(attrs, :mime_type) || Map.get(attrs, "mime_type"),
|
||||
title: Map.get(attrs, :title) || Map.get(attrs, "title") || "",
|
||||
alt: Map.get(attrs, :alt) || Map.get(attrs, "alt") || "",
|
||||
caption: Map.get(attrs, :caption) || Map.get(attrs, "caption") || "",
|
||||
image_url: Map.get(attrs, :image_url) || Map.get(attrs, "image_url")
|
||||
}}
|
||||
end
|
||||
|
||||
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
|
||||
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
|
||||
|
||||
defp media_path_to_file_url(nil), do: nil
|
||||
defp media_path_to_file_url(path), do: "file://" <> path
|
||||
|
||||
defp normalize_string_list(values) do
|
||||
values
|
||||
|> List.wrap()
|
||||
|> Enum.map(&to_string/1)
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms) when is_map(mappings) do
|
||||
import_lookup = canonical_term_lookup(import_terms)
|
||||
existing_lookup = canonical_term_lookup(existing_terms)
|
||||
|
||||
Enum.reduce(mappings, %{}, fn {source, target}, acc ->
|
||||
with {:ok, canonical_source} <- resolve_canonical_term(source, import_lookup),
|
||||
{:ok, canonical_target} <- resolve_canonical_term(target, existing_lookup) do
|
||||
Map.put(acc, canonical_source, canonical_target)
|
||||
else
|
||||
_other -> acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp filter_taxonomy_mapping_response(_mappings, _import_terms, _existing_terms), do: %{}
|
||||
|
||||
defp canonical_term_lookup(terms) do
|
||||
Map.new(terms, fn term -> {normalize_term(term), term} end)
|
||||
end
|
||||
|
||||
defp resolve_canonical_term(term, lookup) do
|
||||
case Map.get(lookup, normalize_term(term)) do
|
||||
nil -> :error
|
||||
canonical -> {:ok, canonical}
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_term(term) do
|
||||
term
|
||||
|> to_string()
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp truncate_text(nil, _max_length), do: ""
|
||||
|
||||
defp truncate_text(text, max_length) when is_binary(text) do
|
||||
if String.length(text) <= max_length do
|
||||
text
|
||||
else
|
||||
String.slice(text, 0, max_length)
|
||||
end
|
||||
end
|
||||
end
|
||||
100
lib/bds/ai/runtime.ex
Normal file
100
lib/bds/ai/runtime.ex
Normal file
@@ -0,0 +1,100 @@
|
||||
defmodule BDS.AI.Runtime do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.AI.Catalog
|
||||
alias BDS.AI.SecretBackend
|
||||
|
||||
@model_preference_keys %{
|
||||
default: "ai.model.default",
|
||||
chat: "ai.model.chat",
|
||||
title: "ai.model.title",
|
||||
image_analysis: "ai.model.image_analysis",
|
||||
airplane_chat: "ai.airplane.model.chat",
|
||||
airplane_title: "ai.airplane.model.title",
|
||||
airplane_image_analysis: "ai.airplane.model.image_analysis"
|
||||
}
|
||||
|
||||
@spec model_preference_keys() :: %{atom() => String.t()}
|
||||
def model_preference_keys, do: @model_preference_keys
|
||||
|
||||
@spec resolve_target(atom(), keyword()) ::
|
||||
{:ok, map(), String.t(), :airplane | :online} | {:error, term()}
|
||||
def resolve_target(operation, extra) do
|
||||
mode = if AI.airplane_mode?(), do: :airplane, else: :online
|
||||
secret_backend = Keyword.get(extra, :secret_backend, SecretBackend)
|
||||
|
||||
with {:ok, endpoint} <- fetch_endpoint_for_mode(mode, secret_backend),
|
||||
{:ok, model} <- resolve_model_for_operation(operation, mode, endpoint, extra) do
|
||||
{:ok, endpoint, model, mode}
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
|
||||
def validate_target(:analyze_image, model, _mode) do
|
||||
if Catalog.model_capabilities(model).supports_attachment do
|
||||
:ok
|
||||
else
|
||||
{:error, %{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
|
||||
end
|
||||
end
|
||||
|
||||
def validate_target(_operation, _model, _mode), do: :ok
|
||||
|
||||
@spec endpoint_with_model(map(), String.t()) :: map()
|
||||
def endpoint_with_model(endpoint, model), do: Map.put(endpoint, :model, model)
|
||||
|
||||
@spec model_preference_value(atom()) :: String.t() | nil
|
||||
def model_preference_value(key) do
|
||||
case AI.get_model_preference(key) do
|
||||
{:ok, value} -> value
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(:chat, :airplane, endpoint, _extra) do
|
||||
{:ok, model_preference_value(:airplane_chat) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(:chat, :online, endpoint, conversation: conversation) do
|
||||
{:ok, conversation.model || model_preference_value(:chat) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(:chat, :online, endpoint, _extra) do
|
||||
{:ok, model_preference_value(:chat) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
|
||||
{:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
|
||||
{:ok, Keyword.get(extra, :model) || model_preference_value(:image_analysis) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(_operation, :airplane, endpoint, extra) do
|
||||
{:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_title) || endpoint.model}
|
||||
end
|
||||
|
||||
defp resolve_model_for_operation(_operation, :online, endpoint, extra) do
|
||||
{:ok, Keyword.get(extra, :model) || model_preference_value(:title) || endpoint.model}
|
||||
end
|
||||
|
||||
defp fetch_endpoint_for_mode(mode, secret_backend) do
|
||||
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do
|
||||
case endpoint do
|
||||
%{url: url, model: model} = loaded when is_binary(url) and url != "" and is_binary(model) and model != "" ->
|
||||
if mode == :online and blank?(loaded.api_key) do
|
||||
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
|
||||
else
|
||||
{:ok, loaded}
|
||||
end
|
||||
|
||||
_other ->
|
||||
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp blank?(value), do: value in [nil, ""]
|
||||
end
|
||||
78
lib/bds/ai/settings_store.ex
Normal file
78
lib/bds/ai/settings_store.ex
Normal file
@@ -0,0 +1,78 @@
|
||||
defmodule BDS.AI.SettingsStore do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.AI.CatalogMeta
|
||||
alias BDS.AI.SecretBackend
|
||||
alias BDS.Persistence
|
||||
alias BDS.Repo
|
||||
alias BDS.Settings.Setting
|
||||
|
||||
@spec get_setting(String.t()) :: String.t() | nil
|
||||
def get_setting(key) do
|
||||
case Repo.get(Setting, key) do
|
||||
nil -> nil
|
||||
setting -> setting.value
|
||||
end
|
||||
end
|
||||
|
||||
@spec put_setting(String.t(), String.t()) :: :ok
|
||||
def put_setting(key, value) when is_binary(key) and is_binary(value) do
|
||||
now = Persistence.now_ms()
|
||||
|
||||
(%Setting{}
|
||||
|> Setting.changeset(%{key: key, value: value, updated_at: now}))
|
||||
|> Repo.insert(
|
||||
on_conflict: [set: [value: value, updated_at: now]],
|
||||
conflict_target: [:key]
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec delete_setting(String.t()) :: :ok
|
||||
def delete_setting(key) do
|
||||
Repo.delete_all(from(setting in Setting, where: setting.key == ^key))
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec put_secret(String.t(), String.t() | nil, module()) :: :ok | {:error, term()}
|
||||
def put_secret(_key, nil, _backend), do: :ok
|
||||
|
||||
def put_secret(key, value, backend) do
|
||||
with {:ok, encrypted_value} <- backend.encrypt(value) do
|
||||
put_setting(encrypted_key(key), encrypted_value)
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_secret(String.t() | nil, module()) :: {:ok, String.t() | nil} | {:error, term()}
|
||||
def get_secret(nil, _backend), do: {:ok, nil}
|
||||
def get_secret(value, backend), do: backend.decrypt(value)
|
||||
|
||||
@spec encrypted_key(String.t()) :: String.t()
|
||||
def encrypted_key(key), do: "__encrypted_#{key}"
|
||||
|
||||
@spec secret_backend() :: module()
|
||||
def secret_backend, do: SecretBackend
|
||||
|
||||
@spec get_catalog_meta_value(String.t()) :: String.t() | nil
|
||||
def get_catalog_meta_value(key) do
|
||||
case Repo.get(CatalogMeta, key) do
|
||||
nil -> get_setting("ai.catalog.#{key}")
|
||||
meta -> meta.value
|
||||
end
|
||||
end
|
||||
|
||||
@spec put_catalog_meta(String.t(), String.t()) :: :ok
|
||||
def put_catalog_meta(key, value) do
|
||||
%CatalogMeta{}
|
||||
|> CatalogMeta.changeset(%{key: key, value: value})
|
||||
|> Repo.insert(
|
||||
on_conflict: [set: [value: value]],
|
||||
conflict_target: [:key]
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large
Load Diff
134
lib/bds/scripting/capabilities/app_shell.ex
Normal file
134
lib/bds/scripting/capabilities/app_shell.ex
Normal file
@@ -0,0 +1,134 @@
|
||||
defmodule BDS.Scripting.Capabilities.AppShell do
|
||||
@moduledoc false
|
||||
|
||||
import BDS.Scripting.Capabilities.Util
|
||||
|
||||
alias BDS.Desktop.FolderPicker
|
||||
alias BDS.Desktop.MenuBar
|
||||
|
||||
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
||||
|
||||
def copy_to_clipboard(text, opts) do
|
||||
case Keyword.get(opts, :copy_to_clipboard) do
|
||||
callback when is_function(callback, 1) -> callback.(string_or_nil(text) || "")
|
||||
_other -> do_copy_to_clipboard(text)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_copy_to_clipboard(text) do
|
||||
if test_mode?() do
|
||||
true
|
||||
else
|
||||
command = string_or_nil(text)
|
||||
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> match?({_output, 0}, System.cmd("pbcopy", [], input: command, stderr_to_stdout: true))
|
||||
{:unix, _other} -> match?({_output, 0}, System.cmd("xclip", ["-selection", "clipboard"], input: command, stderr_to_stdout: true))
|
||||
{:win32, _other} -> match?({_output, 0}, System.cmd("cmd", ["/c", "clip"], input: command, stderr_to_stdout: true))
|
||||
end
|
||||
end
|
||||
rescue
|
||||
_error -> false
|
||||
end
|
||||
|
||||
def blogmark_bookmarklet do
|
||||
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds://new-post?title='+t+'&url='+u;})();"
|
||||
end
|
||||
|
||||
def title_bar_metrics(opts) do
|
||||
case Keyword.get(opts, :title_bar_metrics) do
|
||||
callback when is_function(callback, 0) -> callback.()
|
||||
_other -> do_title_bar_metrics()
|
||||
end
|
||||
end
|
||||
|
||||
defp do_title_bar_metrics do
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> %{macos_left_inset: 72}
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def notify_renderer_ready(opts) do
|
||||
case Keyword.get(opts, :notify_renderer_ready) do
|
||||
callback when is_function(callback, 0) -> callback.()
|
||||
_other -> true
|
||||
end
|
||||
end
|
||||
|
||||
def open_folder(folder_path, opts) do
|
||||
case Keyword.get(opts, :open_folder) do
|
||||
callback when is_function(callback, 1) -> callback.(string_or_nil(folder_path))
|
||||
_other -> do_open_folder(folder_path)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_open_folder(folder_path) do
|
||||
if test_mode?() do
|
||||
""
|
||||
else
|
||||
case shell_open_system_path(string_or_nil(folder_path)) do
|
||||
:ok -> ""
|
||||
{:error, reason} -> inspect(reason)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def select_folder(title, opts) do
|
||||
case Keyword.get(opts, :select_folder) do
|
||||
callback when is_function(callback, 1) -> callback.(string_or_nil(title) || "Select Folder")
|
||||
_other -> do_select_folder(title)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_select_folder(title) do
|
||||
if test_mode?() do
|
||||
nil
|
||||
else
|
||||
case FolderPicker.choose_directory(string_or_nil(title) || "Select Folder") do
|
||||
{:ok, path} -> path
|
||||
:cancel -> nil
|
||||
{:error, _reason} -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def set_preview_post_target(post_id) do
|
||||
:persistent_term.put({BDS.Scripting.Capabilities, :preview_post_target}, string_or_nil(post_id))
|
||||
true
|
||||
end
|
||||
|
||||
def show_item_in_folder(item_path, opts) do
|
||||
callback = Keyword.get(opts, :show_item_in_folder)
|
||||
|
||||
cond do
|
||||
is_function(callback, 1) -> callback.(string_or_nil(item_path))
|
||||
test_mode?() -> :ok
|
||||
true -> _ = shell_reveal_system_path(string_or_nil(item_path))
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def trigger_menu_action(action, opts) do
|
||||
callback = Keyword.get(opts, :trigger_menu_action)
|
||||
|
||||
cond do
|
||||
is_function(callback, 1) -> callback.(string_or_nil(action))
|
||||
test_mode?() -> :ok
|
||||
true -> _ = MenuBar.handle_event(string_or_nil(action), nil)
|
||||
end
|
||||
|
||||
nil
|
||||
rescue
|
||||
_error -> nil
|
||||
end
|
||||
|
||||
def test_mode? do
|
||||
Application.get_env(:bds, :test_mode, false) or current_env() == :test
|
||||
end
|
||||
|
||||
def current_env do
|
||||
Application.get_env(:bds, :current_env_override) || @compiled_env
|
||||
end
|
||||
end
|
||||
176
lib/bds/scripting/capabilities/bridges.ex
Normal file
176
lib/bds/scripting/capabilities/bridges.ex
Normal file
@@ -0,0 +1,176 @@
|
||||
defmodule BDS.Scripting.Capabilities.Bridges do
|
||||
@moduledoc false
|
||||
|
||||
import BDS.Scripting.Capabilities.Util
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.Embeddings
|
||||
alias BDS.Git
|
||||
alias BDS.Media
|
||||
alias BDS.Posts
|
||||
alias BDS.Publishing
|
||||
|
||||
# --- Sync / Git ---
|
||||
|
||||
def sync_available?, do: not is_nil(System.find_executable("git"))
|
||||
|
||||
def repo_state(project_id, opts) do
|
||||
project_id
|
||||
|> Git.repository(git_opts(opts))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def repo_status(project_id, opts) do
|
||||
project_id
|
||||
|> Git.status(git_opts(opts))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def repo_history(project_id, opts) do
|
||||
case Git.repository(project_id, git_opts(opts)) do
|
||||
{:ok, %{current_branch: branch}} when is_binary(branch) and branch != "" ->
|
||||
Git.history(project_id, branch, git_opts(opts))
|
||||
|> unwrap_result()
|
||||
|
||||
_other ->
|
||||
%{"commits" => []}
|
||||
end
|
||||
end
|
||||
|
||||
def repo_fetch(project_id, opts), do: project_id |> Git.fetch(git_opts(opts)) |> unwrap_result()
|
||||
def repo_pull(project_id, opts), do: project_id |> Git.pull(git_opts(opts)) |> unwrap_result()
|
||||
def repo_push(project_id, opts), do: project_id |> Git.push(git_opts(opts)) |> unwrap_result()
|
||||
|
||||
def repo_commit_all(project_id, message, opts) do
|
||||
project_id
|
||||
|> Git.commit_all(string_or_nil(message) || "", git_opts(opts))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
# --- Publishing ---
|
||||
|
||||
def upload_site(project_id, credentials, opts) do
|
||||
project_id
|
||||
|> Publishing.upload_site(normalize_map(credentials), publishing_opts(opts))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
# --- AI ---
|
||||
|
||||
def detect_post_language(title, content, opts) do
|
||||
text = Enum.join([string_or_nil(title) || "", string_or_nil(content) || ""], "\n\n")
|
||||
|
||||
case AI.detect_language(text, ai_opts(opts)) do
|
||||
{:ok, %{language_code: language_code}} -> %{"success" => true, "language" => language_code}
|
||||
{:error, reason} -> %{"success" => false, "error" => inspect(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_post(post_id, opts) do
|
||||
post_id
|
||||
|> string_or_nil()
|
||||
|> AI.analyze_post(ai_opts(opts))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def translate_post(post_id, language, opts) do
|
||||
post_id = string_or_nil(post_id)
|
||||
language = string_or_nil(language) || ""
|
||||
|
||||
with {:ok, translation} <- AI.translate_post(post_id, language, ai_opts(opts)),
|
||||
{:ok, saved_translation} <-
|
||||
Posts.upsert_post_translation(post_id, language, %{
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
content: translation.content
|
||||
}) do
|
||||
sanitize(saved_translation)
|
||||
else
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_media_image(media_id, opts) do
|
||||
case AI.analyze_image(string_or_nil(media_id), ai_opts(opts)) do
|
||||
{:ok, result} -> sanitize(result)
|
||||
{:error, _reason} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def detect_media_language(title, alt, caption, opts) do
|
||||
text = Enum.join([string_or_nil(title) || "", string_or_nil(alt) || "", string_or_nil(caption) || ""], "\n")
|
||||
|
||||
case AI.detect_language(text, ai_opts(opts)) do
|
||||
{:ok, %{language_code: language_code}} -> %{"success" => true, "language" => language_code}
|
||||
{:error, reason} -> %{"success" => false, "error" => inspect(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
def translate_media_metadata(media_id, language, opts) do
|
||||
media_id = string_or_nil(media_id)
|
||||
language = string_or_nil(language) || ""
|
||||
|
||||
with {:ok, translation} <- AI.translate_media(media_id, language, ai_opts(opts)),
|
||||
{:ok, saved_translation} <-
|
||||
Media.upsert_media_translation(media_id, language, %{
|
||||
title: translation.title,
|
||||
alt: translation.alt,
|
||||
caption: translation.caption
|
||||
}) do
|
||||
sanitize(saved_translation)
|
||||
else
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# --- Embeddings ---
|
||||
|
||||
def embedding_progress(project_id), do: project_id |> Embeddings.get_indexing_progress() |> unwrap_result()
|
||||
|
||||
def find_similar(post_id, limit) do
|
||||
post_id
|
||||
|> string_or_nil()
|
||||
|> Embeddings.find_similar(integer_or_default(limit, 5))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def compute_similarities(post_id, target_ids) do
|
||||
post_id
|
||||
|> string_or_nil()
|
||||
|> Embeddings.compute_similarities(normalize_string_list(target_ids))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def suggest_tags(post_id, exclude_tags) do
|
||||
post_id
|
||||
|> string_or_nil()
|
||||
|> Embeddings.suggest_tags(normalize_string_list(exclude_tags))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def find_duplicates(project_id), do: project_id |> Embeddings.find_duplicates() |> unwrap_result()
|
||||
def dismiss_pair(post_id_a, post_id_b), do: atom_result(Embeddings.dismiss_duplicate_pair(string_or_nil(post_id_a) || "", string_or_nil(post_id_b) || ""), :ok)
|
||||
def index_unindexed_posts(project_id), do: project_id |> Embeddings.index_unindexed() |> unwrap_result()
|
||||
|
||||
# --- Opt builders ---
|
||||
|
||||
def git_opts(opts) do
|
||||
case Keyword.get(opts, :git_runner) do
|
||||
nil -> []
|
||||
runner -> [runner: runner]
|
||||
end
|
||||
end
|
||||
|
||||
def publishing_opts(opts) do
|
||||
case Keyword.get(opts, :publishing_uploader) do
|
||||
nil -> []
|
||||
uploader -> [uploader: uploader]
|
||||
end
|
||||
end
|
||||
|
||||
def ai_opts(opts) do
|
||||
[]
|
||||
|> maybe_put_opt(:runtime, Keyword.get(opts, :ai_runtime))
|
||||
|> maybe_put_opt(:secret_backend, Keyword.get(opts, :ai_secret_backend))
|
||||
end
|
||||
end
|
||||
284
lib/bds/scripting/capabilities/crud.ex
Normal file
284
lib/bds/scripting/capabilities/crud.ex
Normal file
@@ -0,0 +1,284 @@
|
||||
defmodule BDS.Scripting.Capabilities.Crud do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.Scripting.Capabilities.Util
|
||||
|
||||
alias BDS.MCP
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Repo
|
||||
alias BDS.Scripting.Capabilities.Posts, as: PostsCaps
|
||||
alias BDS.Scripts
|
||||
alias BDS.Scripts.Script
|
||||
alias BDS.Tags
|
||||
alias BDS.Tags.Tag
|
||||
alias BDS.Tasks
|
||||
alias BDS.Templates
|
||||
alias BDS.Templates.Template
|
||||
|
||||
# --- Scripts ---
|
||||
|
||||
def create_script(project_id, attrs) do
|
||||
attrs
|
||||
|> normalize_map()
|
||||
|> Map.put("project_id", project_id)
|
||||
|> Scripts.create_script()
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def update_script(project_id, script_id, attrs) do
|
||||
case fetch_script(project_id, script_id) do
|
||||
%Script{} -> Scripts.update_script(script_id, normalize_map(attrs)) |> unwrap_result()
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def delete_script(project_id, script_id) do
|
||||
case fetch_script(project_id, script_id) do
|
||||
%Script{} -> boolean_result(Scripts.delete_script(script_id))
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
|
||||
def load_script(project_id, script_id) do
|
||||
fetch_script(project_id, script_id)
|
||||
|> sanitize_nilable()
|
||||
end
|
||||
|
||||
def list_scripts(project_id) do
|
||||
Repo.all(
|
||||
from(script in Script, where: script.project_id == ^project_id, order_by: [asc: script.created_at])
|
||||
)
|
||||
|> Enum.map(&sanitize/1)
|
||||
end
|
||||
|
||||
def publish_script(project_id, script_id) do
|
||||
case fetch_script(project_id, script_id) do
|
||||
%Script{} -> Scripts.publish_script(script_id) |> unwrap_result()
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def rebuild_scripts_from_files(project_id) do
|
||||
project_id
|
||||
|> Scripts.rebuild_scripts_from_files()
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def fetch_script(project_id, script_id) do
|
||||
Repo.one(
|
||||
from(script in Script,
|
||||
where: script.project_id == ^project_id and script.id == ^(string_or_nil(script_id) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# --- Templates ---
|
||||
|
||||
def create_template(project_id, attrs) do
|
||||
attrs
|
||||
|> normalize_map()
|
||||
|> Map.put("project_id", project_id)
|
||||
|> Templates.create_template()
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def update_template(project_id, template_id, attrs) do
|
||||
case fetch_template(project_id, template_id) do
|
||||
%Template{} -> Templates.update_template(template_id, normalize_map(attrs)) |> unwrap_result()
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def delete_template(project_id, template_id) do
|
||||
case fetch_template(project_id, template_id) do
|
||||
%Template{} -> boolean_result(Templates.delete_template(template_id))
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
|
||||
def load_template(project_id, template_id) do
|
||||
fetch_template(project_id, template_id)
|
||||
|> sanitize_nilable()
|
||||
end
|
||||
|
||||
def list_templates(project_id) do
|
||||
Repo.all(
|
||||
from(template in Template, where: template.project_id == ^project_id, order_by: [asc: template.created_at])
|
||||
)
|
||||
|> Enum.map(&sanitize/1)
|
||||
end
|
||||
|
||||
def publish_template(project_id, template_id) do
|
||||
case fetch_template(project_id, template_id) do
|
||||
%Template{} -> Templates.publish_template(template_id) |> unwrap_result()
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def list_enabled_templates(project_id, kind) do
|
||||
Repo.all(
|
||||
from(template in Template,
|
||||
where:
|
||||
template.project_id == ^project_id and template.enabled == true and
|
||||
template.kind == ^string_or_nil(kind),
|
||||
order_by: [asc: template.created_at]
|
||||
)
|
||||
)
|
||||
|> Enum.map(&sanitize/1)
|
||||
end
|
||||
|
||||
def rebuild_templates_from_files(project_id) do
|
||||
project_id
|
||||
|> Templates.rebuild_templates_from_files()
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def validate_template_source(source) do
|
||||
source
|
||||
|> string_or_nil()
|
||||
|> Kernel.||("")
|
||||
|> MCP.validate_template()
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def fetch_template(project_id, template_id) do
|
||||
Repo.one(
|
||||
from(template in Template,
|
||||
where: template.project_id == ^project_id and template.id == ^(string_or_nil(template_id) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# --- Tags ---
|
||||
|
||||
def create_tag(project_id, attrs) do
|
||||
attrs
|
||||
|> normalize_map()
|
||||
|> Map.put("project_id", project_id)
|
||||
|> Tags.create_tag()
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def update_tag(project_id, tag_id, attrs) do
|
||||
case fetch_tag(project_id, tag_id) do
|
||||
%Tag{} -> Tags.update_tag(tag_id, normalize_map(attrs)) |> unwrap_result()
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def delete_tag(project_id, tag_id) do
|
||||
case fetch_tag(project_id, tag_id) do
|
||||
%Tag{} -> boolean_result(Tags.delete_tag(tag_id))
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
|
||||
def load_tag(project_id, tag_id) do
|
||||
fetch_tag(project_id, tag_id)
|
||||
|> sanitize_nilable()
|
||||
end
|
||||
|
||||
def list_tags(project_id) do
|
||||
Tags.list_tags(project_id)
|
||||
|> Enum.map(&sanitize/1)
|
||||
end
|
||||
|
||||
def tags_with_counts(project_id) do
|
||||
counts_by_name =
|
||||
PostsCaps.names_with_counts(project_id, :tags)
|
||||
|> Map.new(fn entry -> {entry["name"], entry["count"]} end)
|
||||
|
||||
list_tags(project_id)
|
||||
|> Enum.map(fn tag -> Map.put(tag, "count", Map.get(counts_by_name, tag["name"], 0)) end)
|
||||
end
|
||||
|
||||
def tag_post_ids(project_id, tag_id) do
|
||||
case fetch_tag(project_id, tag_id) do
|
||||
%Tag{name: tag_name} ->
|
||||
Repo.all(
|
||||
from(post in Post,
|
||||
where: post.project_id == ^project_id,
|
||||
order_by: [asc: post.created_at]
|
||||
)
|
||||
)
|
||||
|> Enum.filter(&(tag_name in (&1.tags || [])))
|
||||
|> Enum.map(& &1.id)
|
||||
|
||||
_other ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def load_tag_by_name(project_id, tag_name) do
|
||||
Repo.one(
|
||||
from(tag in Tag,
|
||||
where:
|
||||
tag.project_id == ^project_id and
|
||||
fragment("lower(?)", tag.name) == ^String.downcase(string_or_nil(tag_name) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
|> sanitize_nilable()
|
||||
end
|
||||
|
||||
def rename_tag(project_id, tag_id, new_name) do
|
||||
case fetch_tag(project_id, tag_id) do
|
||||
%Tag{} -> Tags.rename_tag(tag_id, string_or_nil(new_name) || "") |> unwrap_result()
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def merge_tags(project_id, source_tag_ids, target_tag_id) do
|
||||
case fetch_tag(project_id, target_tag_id) do
|
||||
%Tag{} -> atom_result(Tags.merge_tags(normalize_string_list(source_tag_ids), target_tag_id), :merged)
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
|
||||
def sync_tags_from_posts(project_id) do
|
||||
Tags.sync_tags_from_posts(project_id)
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def fetch_tag(project_id, tag_id) do
|
||||
Repo.one(
|
||||
from(tag in Tag,
|
||||
where: tag.project_id == ^project_id and tag.id == ^(string_or_nil(tag_id) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# --- Tasks ---
|
||||
|
||||
def load_task(task_id) do
|
||||
case string_or_nil(task_id) do
|
||||
nil -> nil
|
||||
id -> Tasks.get_task(id) |> sanitize_nilable()
|
||||
end
|
||||
end
|
||||
|
||||
def cancel_task(task_id) do
|
||||
case string_or_nil(task_id) do
|
||||
nil -> false
|
||||
id -> match?(:ok, Tasks.cancel_task(id))
|
||||
end
|
||||
end
|
||||
|
||||
def list_all_tasks do
|
||||
Tasks.list_tasks()
|
||||
|> Enum.map(&sanitize/1)
|
||||
end
|
||||
|
||||
def list_running_tasks do
|
||||
Tasks.list_running_tasks()
|
||||
|> Enum.map(&sanitize/1)
|
||||
end
|
||||
|
||||
def clear_completed_tasks do
|
||||
match?(:ok, Tasks.clear_completed())
|
||||
end
|
||||
end
|
||||
254
lib/bds/scripting/capabilities/media.ex
Normal file
254
lib/bds/scripting/capabilities/media.ex
Normal file
@@ -0,0 +1,254 @@
|
||||
defmodule BDS.Scripting.Capabilities.Media do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.Scripting.Capabilities.Util
|
||||
|
||||
alias BDS.Media
|
||||
alias BDS.Media.Media, as: MediaRecord
|
||||
alias BDS.Media.Translation, as: MediaTranslation
|
||||
alias BDS.Repo
|
||||
alias BDS.Search
|
||||
|
||||
def import_media(project_id, attrs) do
|
||||
attrs
|
||||
|> normalize_map()
|
||||
|> normalize_media_attrs()
|
||||
|> Map.put("project_id", project_id)
|
||||
|> Media.import_media()
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def update_media(project_id, media_id, attrs) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{} -> Media.update_media(media_id, attrs |> normalize_map() |> normalize_media_attrs()) |> unwrap_result()
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def delete_media(project_id, media_id) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{} -> boolean_result(Media.delete_media(media_id))
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
|
||||
def load_media(project_id, media_id) do
|
||||
fetch_media(project_id, media_id)
|
||||
|> sanitize_nilable()
|
||||
end
|
||||
|
||||
def list_media(project_id) do
|
||||
Repo.all(
|
||||
from(media in MediaRecord, where: media.project_id == ^project_id, order_by: [asc: media.created_at])
|
||||
)
|
||||
|> Enum.map(&sanitize/1)
|
||||
end
|
||||
|
||||
def load_media_translation(project_id, media_id, language) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{id: id} ->
|
||||
Repo.one(
|
||||
from(translation in MediaTranslation,
|
||||
where:
|
||||
translation.translation_for == ^id and
|
||||
translation.language == ^(string_or_nil(language) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
|> sanitize_nilable()
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def list_media_translations(project_id, media_id) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{id: id} ->
|
||||
Repo.all(
|
||||
from(translation in MediaTranslation,
|
||||
where: translation.translation_for == ^id,
|
||||
order_by: [asc: translation.language]
|
||||
)
|
||||
)
|
||||
|> Enum.map(&sanitize/1)
|
||||
|
||||
_other ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_media_translation(project_id, media_id, language, attrs) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{} ->
|
||||
Media.upsert_media_translation(media_id, string_or_nil(language) || "", normalize_media_translation_attrs(normalize_map(attrs)))
|
||||
|> unwrap_result()
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def delete_media_translation(project_id, media_id, language) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{} ->
|
||||
case Media.delete_media_translation(media_id, string_or_nil(language) || "") do
|
||||
{:ok, deleted?} -> deleted?
|
||||
{:error, _reason} -> false
|
||||
end
|
||||
|
||||
_other ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def filter_media(project_id, filters) do
|
||||
filters = normalize_map(filters)
|
||||
|
||||
list_media(project_id)
|
||||
|> Enum.filter(fn media -> media_matches_filters?(media, filters) end)
|
||||
end
|
||||
|
||||
def media_counts_by_year_month(project_id) do
|
||||
list_media(project_id)
|
||||
|> Enum.reduce(%{}, fn media, acc ->
|
||||
datetime = media_datetime(media)
|
||||
key = {datetime.year, datetime.month}
|
||||
Map.update(acc, key, 1, &(&1 + 1))
|
||||
end)
|
||||
|> Enum.map(fn {{year, month}, count} -> %{"year" => year, "month" => month, "count" => count} end)
|
||||
|> Enum.sort_by(fn row -> {-row["year"], -row["month"]} end)
|
||||
end
|
||||
|
||||
def media_file_path(project_id, media_id) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{} = media -> Path.join(project_path(project_id), media.file_path)
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def media_tags(project_id), do: media_tags_with_counts(project_id) |> Enum.map(& &1["tag"])
|
||||
|
||||
def media_tags_with_counts(project_id) do
|
||||
Repo.all(from(media in MediaRecord, where: media.project_id == ^project_id, order_by: [asc: media.created_at]))
|
||||
|> Enum.flat_map(&(&1.tags || []))
|
||||
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
|
||||
|> Enum.map(fn {tag, count} -> %{"tag" => tag, "count" => count} end)
|
||||
|> Enum.sort_by(fn row -> {-row["count"], String.downcase(row["tag"])} end)
|
||||
end
|
||||
|
||||
def media_thumbnail(project_id, media_id, size) do
|
||||
with %MediaRecord{} = media <- fetch_media(project_id, media_id),
|
||||
relative_path <- Media.thumbnail_paths(media)[thumbnail_size(size)],
|
||||
absolute_path <- Path.join(project_path(project_id), relative_path),
|
||||
true <- File.exists?(absolute_path),
|
||||
{:ok, binary} <- File.read(absolute_path) do
|
||||
"data:#{thumbnail_mime(absolute_path)};base64," <> Base.encode64(binary)
|
||||
else
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def media_url(project_id, media_id) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{} = media -> "/" <> String.trim_leading(media.file_path, "/")
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def rebuild_media_from_files(project_id) do
|
||||
project_id
|
||||
|> Media.rebuild_media_from_files()
|
||||
|> unwrap_result(fn media -> Enum.map(media, &sanitize/1) end)
|
||||
end
|
||||
|
||||
def regenerate_missing_thumbnails(project_id) do
|
||||
Media.regenerate_missing_thumbnails(project_id)
|
||||
|> sanitize()
|
||||
end
|
||||
|
||||
def regenerate_media_thumbnails(project_id, media_id) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{} = media ->
|
||||
case Media.regenerate_thumbnails(media.id) do
|
||||
{:ok, _media} ->
|
||||
Media.thumbnail_paths(media)
|
||||
|> Enum.map(fn {size, relative_path} -> {to_string(size), Path.join(project_path(project_id), relative_path)} end)
|
||||
|> Map.new()
|
||||
|
||||
{:error, _reason} ->
|
||||
nil
|
||||
end
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def replace_media_file(project_id, media_id, source_path) do
|
||||
case fetch_media(project_id, media_id) do
|
||||
%MediaRecord{} ->
|
||||
Media.replace_media_file(media_id, string_or_nil(source_path) || "")
|
||||
|> unwrap_result()
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def search_media(project_id, query) do
|
||||
project_id
|
||||
|> Search.search_media(string_or_nil(query) || "")
|
||||
|> unwrap_result(fn %{media: media} -> Enum.map(media, &sanitize/1) end)
|
||||
end
|
||||
|
||||
def fetch_media(project_id, media_id) do
|
||||
Repo.one(
|
||||
from(media in MediaRecord,
|
||||
where: media.project_id == ^project_id and media.id == ^(string_or_nil(media_id) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def normalize_media_attrs(attrs) do
|
||||
attrs
|
||||
|> maybe_put_normalized_list("tags")
|
||||
end
|
||||
|
||||
def normalize_media_translation_attrs(attrs) do
|
||||
attrs
|
||||
|> Map.take(["title", "alt", "caption"])
|
||||
end
|
||||
|
||||
def media_matches_filters?(media, filters) do
|
||||
created_at = media_datetime(media)
|
||||
tags = Map.get(media, "tags", [])
|
||||
language = Map.get(media, "language")
|
||||
|
||||
matches_year = compare_optional(Map.get(filters, "year"), fn year -> created_at.year == integer_or_default(year, created_at.year) end)
|
||||
matches_month = compare_optional(Map.get(filters, "month"), fn month -> created_at.month == integer_or_default(month, created_at.month) end)
|
||||
matches_language = compare_optional(blank_to_nil(Map.get(filters, "language")), fn value -> language == value end)
|
||||
matches_tags = compare_optional(Map.get(filters, "tags"), fn required_tags -> Enum.all?(normalize_string_list(required_tags), &(&1 in tags)) end)
|
||||
matches_from = compare_optional(parse_datetime(Map.get(filters, "from") || Map.get(filters, "start_date")), fn from_dt -> DateTime.compare(created_at, from_dt) != :lt end)
|
||||
matches_to = compare_optional(parse_datetime(Map.get(filters, "to") || Map.get(filters, "end_date")), fn to_dt -> DateTime.compare(created_at, to_dt) != :gt end)
|
||||
|
||||
matches_year and matches_month and matches_language and matches_tags and matches_from and matches_to
|
||||
end
|
||||
|
||||
def media_datetime(media) do
|
||||
media
|
||||
|> Map.get("created_at")
|
||||
|> case do
|
||||
value when is_binary(value) ->
|
||||
case DateTime.from_iso8601(value) do
|
||||
{:ok, datetime, _offset} -> datetime
|
||||
_other -> DateTime.utc_now()
|
||||
end
|
||||
|
||||
value when is_integer(value) -> DateTime.from_unix!(value, :millisecond)
|
||||
_other -> DateTime.utc_now()
|
||||
end
|
||||
end
|
||||
end
|
||||
270
lib/bds/scripting/capabilities/posts.ex
Normal file
270
lib/bds/scripting/capabilities/posts.ex
Normal file
@@ -0,0 +1,270 @@
|
||||
defmodule BDS.Scripting.Capabilities.Posts do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.Scripting.Capabilities.Util
|
||||
|
||||
alias BDS.PostLinks
|
||||
alias BDS.Posts
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Posts.Translation, as: PostTranslation
|
||||
alias BDS.Preview
|
||||
alias BDS.Repo
|
||||
alias BDS.Search
|
||||
|
||||
def create_post(project_id, attrs) do
|
||||
attrs
|
||||
|> normalize_map()
|
||||
|> Map.put("project_id", project_id)
|
||||
|> Posts.create_post()
|
||||
|> unwrap_result(&post_payload/1)
|
||||
end
|
||||
|
||||
def update_post(project_id, post_id, attrs) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{} -> Posts.update_post(post_id, normalize_map(attrs)) |> unwrap_result(&post_payload/1)
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def delete_post(project_id, post_id) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{} -> boolean_result(Posts.delete_post(post_id))
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
|
||||
def load_post(project_id, post_id) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{} = post -> post_payload(post)
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def list_posts(project_id) do
|
||||
Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at]))
|
||||
|> Enum.map(&post_payload/1)
|
||||
end
|
||||
|
||||
def load_post_by_slug(project_id, slug) do
|
||||
Repo.one(
|
||||
from(post in Post,
|
||||
where: post.project_id == ^project_id and post.slug == ^(string_or_nil(slug) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
|> case do
|
||||
%Post{} = post -> post_payload(post)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def publish_post(project_id, post_id) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{} -> Posts.publish_post(post_id) |> unwrap_result(&post_payload/1)
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def discard_post(project_id, post_id) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{} -> Posts.discard_post_changes(post_id) |> unwrap_result(&post_payload/1)
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def filter_posts(project_id, filters) do
|
||||
project_id
|
||||
|> Search.search_posts("", normalize_search_filters(filters))
|
||||
|> unwrap_result(fn %{posts: posts} -> Enum.map(posts, &post_payload/1) end)
|
||||
end
|
||||
|
||||
def generate_unique_post_slug(project_id, title, exclude_post_id) do
|
||||
Posts.unique_slug_for_title(project_id, string_or_nil(title) || "", string_or_nil(exclude_post_id))
|
||||
end
|
||||
|
||||
def posts_by_status(project_id, status) do
|
||||
normalized_status = string_or_nil(status) || ""
|
||||
|
||||
Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at]))
|
||||
|> Enum.filter(&(to_string(&1.status) == normalized_status))
|
||||
|> Enum.map(&post_payload/1)
|
||||
end
|
||||
|
||||
def post_counts_by_year_month(project_id) do
|
||||
Posts.post_counts_by_year_month(project_id)
|
||||
|> sanitize()
|
||||
end
|
||||
|
||||
def post_dashboard_stats(project_id) do
|
||||
Posts.dashboard_stats(project_id)
|
||||
|> sanitize()
|
||||
end
|
||||
|
||||
def linked_posts_for(project_id, post_id, direction) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{id: id} -> linked_posts(id, direction)
|
||||
_other -> []
|
||||
end
|
||||
end
|
||||
|
||||
def preview_url(project_id, post_id, options) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{} = post ->
|
||||
with {:ok, server} <- Preview.start_preview(project_id) do
|
||||
base_url = "http://#{server.host}:#{server.port}"
|
||||
canonical_path = canonical_preview_path(post.created_at, post.slug)
|
||||
options = normalize_map(options)
|
||||
language = options |> Map.get("lang") |> string_or_nil() |> blank_to_nil()
|
||||
|
||||
query =
|
||||
%{}
|
||||
|> maybe_put_query("draft", truthy?(Map.get(options, "draft")) && "true")
|
||||
|> maybe_put_query("post_id", truthy?(Map.get(options, "draft")) && post.id)
|
||||
|> maybe_put_query("lang", language)
|
||||
|
||||
if map_size(query) == 0 do
|
||||
base_url <> canonical_path
|
||||
else
|
||||
base_url <> canonical_path <> "?" <> URI.encode_query(query)
|
||||
end
|
||||
else
|
||||
_other -> nil
|
||||
end
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def post_slug_available?(project_id, slug, exclude_post_id) do
|
||||
Posts.slug_available(project_id, string_or_nil(slug) || "", string_or_nil(exclude_post_id))
|
||||
end
|
||||
|
||||
def publish_post_translation(project_id, post_id, language) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{} -> Posts.publish_post_translation(post_id, string_or_nil(language) || "") |> unwrap_result()
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def rebuild_post_links(project_id) do
|
||||
case Posts.rebuild_post_links(project_id) do
|
||||
:ok -> true
|
||||
end
|
||||
end
|
||||
|
||||
def rebuild_posts_from_files(project_id) do
|
||||
project_id
|
||||
|> Posts.rebuild_posts_from_files()
|
||||
|> unwrap_result(fn posts -> Enum.map(posts, &post_payload/1) end)
|
||||
end
|
||||
|
||||
def reindex_project_search(project_id) do
|
||||
case Search.reindex_project(project_id) do
|
||||
:ok -> true
|
||||
end
|
||||
end
|
||||
|
||||
def search_posts(project_id, query) do
|
||||
project_id
|
||||
|> Search.search_posts(string_or_nil(query) || "")
|
||||
|> unwrap_result(fn %{posts: posts} -> Enum.map(posts, &post_payload/1) end)
|
||||
end
|
||||
|
||||
def post_tags(project_id), do: names_with_counts(project_id, :tags) |> Enum.map(& &1["name"])
|
||||
def post_tags_with_counts(project_id), do: names_with_counts(project_id, :tags)
|
||||
def post_categories(project_id), do: names_with_counts(project_id, :categories) |> Enum.map(& &1["name"])
|
||||
def post_categories_with_counts(project_id), do: names_with_counts(project_id, :categories)
|
||||
|
||||
def list_post_translations(project_id, post_id) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{id: id} ->
|
||||
id
|
||||
|> Posts.list_post_translations()
|
||||
|> unwrap_result(fn translations -> Enum.map(translations, &sanitize/1) end)
|
||||
|
||||
_other ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def load_post_translation(project_id, post_id, language) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{id: id} ->
|
||||
Repo.one(
|
||||
from(translation in PostTranslation,
|
||||
where:
|
||||
translation.translation_for == ^id and
|
||||
translation.language == ^(string_or_nil(language) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
|> sanitize_nilable()
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def has_published_post_version(project_id, post_id) do
|
||||
case fetch_post(project_id, post_id) do
|
||||
%Post{status: :published} -> true
|
||||
%Post{published_at: published_at, file_path: file_path} -> not is_nil(published_at) or file_path not in [nil, ""]
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_post(project_id, post_id) do
|
||||
Repo.one(
|
||||
from(post in Post,
|
||||
where: post.project_id == ^project_id and post.id == ^(string_or_nil(post_id) || ""),
|
||||
limit: 1
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def post_payload(%Post{} = post) do
|
||||
post
|
||||
|> sanitize()
|
||||
|> Map.put("backlinks", linked_posts(post.id, :incoming))
|
||||
|> Map.put("links_to", linked_posts(post.id, :outgoing))
|
||||
end
|
||||
|
||||
def linked_posts(post_id, :incoming) do
|
||||
PostLinks.list_incoming_links(post_id)
|
||||
|> Enum.map(&load_linked_post(&1.source_post_id))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
def linked_posts(post_id, :outgoing) do
|
||||
PostLinks.list_outgoing_links(post_id)
|
||||
|> Enum.map(&load_linked_post(&1.target_post_id))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
defp load_linked_post(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
%Post{} = post -> %{"id" => post.id, "title" => post.title, "slug" => post.slug}
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp canonical_preview_path(created_at_ms, slug) do
|
||||
datetime = DateTime.from_unix!(created_at_ms, :millisecond)
|
||||
"/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{string_or_nil(slug) || ""}"
|
||||
end
|
||||
|
||||
def names_with_counts(project_id, field) when field in [:tags, :categories] do
|
||||
Repo.all(
|
||||
from(post in Post,
|
||||
where: post.project_id == ^project_id,
|
||||
order_by: [asc: post.created_at]
|
||||
)
|
||||
)
|
||||
|> Enum.flat_map(&(Map.get(&1, field) || []))
|
||||
|> Enum.reduce(%{}, fn name, acc -> Map.update(acc, name, 1, &(&1 + 1)) end)
|
||||
|> Enum.map(fn {name, count} -> %{"name" => name, "count" => count} end)
|
||||
|> Enum.sort_by(&{String.downcase(&1["name"]), &1["count"]})
|
||||
end
|
||||
end
|
||||
204
lib/bds/scripting/capabilities/projects.ex
Normal file
204
lib/bds/scripting/capabilities/projects.ex
Normal file
@@ -0,0 +1,204 @@
|
||||
defmodule BDS.Scripting.Capabilities.Projects do
|
||||
@moduledoc false
|
||||
|
||||
import BDS.Scripting.Capabilities.Crud
|
||||
import BDS.Scripting.Capabilities.Util
|
||||
|
||||
alias BDS.Metadata
|
||||
alias BDS.Projects, as: ProjectsCtx
|
||||
alias BDS.Projects.Project
|
||||
alias BDS.Repo
|
||||
alias BDS.Tags
|
||||
|
||||
def create_project(attrs), do: attrs |> normalize_map() |> ProjectsCtx.create_project() |> unwrap_result()
|
||||
|
||||
def delete_project(project_id), do: boolean_result(ProjectsCtx.delete_project(string_or_nil(project_id)))
|
||||
|
||||
def delete_project_with_data(project_id) do
|
||||
case string_or_nil(project_id) && ProjectsCtx.get_project(string_or_nil(project_id)) do
|
||||
%Project{} = project ->
|
||||
data_dir = ProjectsCtx.project_data_dir(project)
|
||||
|
||||
case ProjectsCtx.delete_project(project.id) do
|
||||
{:ok, _deleted_project} ->
|
||||
_ = File.rm_rf(data_dir)
|
||||
true
|
||||
|
||||
{:error, _reason} ->
|
||||
false
|
||||
end
|
||||
|
||||
_other ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def load_project(project_id) do
|
||||
case string_or_nil(project_id) do
|
||||
nil -> nil
|
||||
id -> ProjectsCtx.get_project(id) |> sanitize_nilable()
|
||||
end
|
||||
end
|
||||
|
||||
def list_projects do
|
||||
ProjectsCtx.list_projects()
|
||||
|> Enum.map(&sanitize/1)
|
||||
end
|
||||
|
||||
def set_active_project(project_id) do
|
||||
project_id
|
||||
|> string_or_nil()
|
||||
|> then(fn
|
||||
nil -> {:error, :not_found}
|
||||
id -> ProjectsCtx.set_active_project(id)
|
||||
end)
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def update_project(project_id, attrs) do
|
||||
case string_or_nil(project_id) && ProjectsCtx.get_project(string_or_nil(project_id)) do
|
||||
%Project{} = project ->
|
||||
attrs = normalize_map(attrs)
|
||||
|
||||
updates = %{
|
||||
name: Map.get(attrs, "name", project.name),
|
||||
description: Map.get(attrs, "description", project.description),
|
||||
data_path: Map.get(attrs, "data_path", project.data_path),
|
||||
updated_at: System.system_time(:millisecond),
|
||||
is_active: Map.get(attrs, "is_active", project.is_active)
|
||||
}
|
||||
|
||||
project
|
||||
|> Project.changeset(updates)
|
||||
|> Repo.update()
|
||||
|> unwrap_result()
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def load_metadata(project_id) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
sanitize(metadata)
|
||||
end
|
||||
|
||||
def update_project_metadata(project_id, attrs) do
|
||||
Metadata.update_project_metadata(project_id, normalize_map(attrs))
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def add_category(project_id, name) do
|
||||
Metadata.add_category(project_id, string_or_nil(name) || "")
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def remove_category(project_id, name) do
|
||||
Metadata.remove_category(project_id, string_or_nil(name) || "")
|
||||
|> unwrap_result()
|
||||
end
|
||||
|
||||
def metadata_categories(project_id) do
|
||||
load_metadata(project_id)
|
||||
|> Map.get("categories", [])
|
||||
end
|
||||
|
||||
def metadata_tags(project_id) do
|
||||
project_id
|
||||
|> list_tags()
|
||||
|> Enum.map(&Map.get(&1, "name"))
|
||||
end
|
||||
|
||||
def add_meta_tag(project_id, name) do
|
||||
normalized_name = string_or_nil(name) |> to_string() |> String.trim()
|
||||
|
||||
cond do
|
||||
normalized_name == "" -> metadata_tags(project_id)
|
||||
load_tag_by_name(project_id, normalized_name) -> metadata_tags(project_id)
|
||||
true ->
|
||||
create_tag(project_id, %{"name" => normalized_name})
|
||||
metadata_tags(project_id)
|
||||
end
|
||||
end
|
||||
|
||||
def remove_meta_tag(project_id, name) do
|
||||
case load_tag_by_name(project_id, name) do
|
||||
%{"id" => tag_id} ->
|
||||
_ = delete_tag(project_id, tag_id)
|
||||
metadata_tags(project_id)
|
||||
|
||||
_other ->
|
||||
metadata_tags(project_id)
|
||||
end
|
||||
end
|
||||
|
||||
def publishing_preferences(project_id) do
|
||||
load_metadata(project_id)
|
||||
|> Map.get("publishing_preferences")
|
||||
end
|
||||
|
||||
def set_publishing_preferences(project_id, prefs) do
|
||||
project_id
|
||||
|> Metadata.set_publishing_preferences(normalize_map(prefs))
|
||||
|> unwrap_result()
|
||||
|> case do
|
||||
nil -> nil
|
||||
metadata -> Map.get(metadata, "publishing_preferences")
|
||||
end
|
||||
end
|
||||
|
||||
def clear_publishing_preferences(project_id) do
|
||||
set_publishing_preferences(project_id, %{})
|
||||
end
|
||||
|
||||
def sync_meta_on_startup(project_id) do
|
||||
_ = Tags.sync_tags_from_posts(project_id)
|
||||
|
||||
%{
|
||||
tags: metadata_tags(project_id),
|
||||
categories: metadata_categories(project_id),
|
||||
project_metadata: load_metadata(project_id)
|
||||
}
|
||||
end
|
||||
|
||||
def data_paths(project_id) do
|
||||
database_path = Repo.config()[:database]
|
||||
project_dir = project_path(project_id)
|
||||
|
||||
%{
|
||||
database: database_path,
|
||||
project: project_dir,
|
||||
posts: Path.join(project_dir, "posts"),
|
||||
media: Path.join(project_dir, "media")
|
||||
}
|
||||
end
|
||||
|
||||
def read_project_metadata(folder_path) do
|
||||
case project_for_folder(folder_path) do
|
||||
nil -> read_project_metadata_file(folder_path)
|
||||
project -> load_metadata(project.id)
|
||||
end
|
||||
end
|
||||
|
||||
def project_for_folder(folder_path) do
|
||||
normalized = string_or_nil(folder_path)
|
||||
|
||||
ProjectsCtx.list_projects()
|
||||
|> Enum.find(fn project -> ProjectsCtx.project_data_dir(project) == normalized end)
|
||||
end
|
||||
|
||||
def read_project_metadata_file(folder_path) do
|
||||
path = Path.join([string_or_nil(folder_path) || "", "meta", "project.json"])
|
||||
|
||||
case File.read(path) do
|
||||
{:ok, contents} ->
|
||||
case Jason.decode(contents) do
|
||||
{:ok, decoded} when is_map(decoded) -> sanitize(decoded)
|
||||
_other -> nil
|
||||
end
|
||||
|
||||
{:error, _reason} ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
301
lib/bds/scripting/capabilities/util.ex
Normal file
301
lib/bds/scripting/capabilities/util.ex
Normal file
@@ -0,0 +1,301 @@
|
||||
defmodule BDS.Scripting.Capabilities.Util do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.Projects
|
||||
|
||||
def project_path(project_id) do
|
||||
project_id
|
||||
|> Projects.get_project()
|
||||
|> Projects.project_data_dir()
|
||||
end
|
||||
|
||||
def sanitize(%DateTime{} = value), do: DateTime.to_iso8601(value)
|
||||
|
||||
def sanitize(%_struct{} = struct) do
|
||||
struct
|
||||
|> Map.from_struct()
|
||||
|> Map.drop([:__meta__, :post, :project, :media, :translations])
|
||||
|> sanitize()
|
||||
end
|
||||
|
||||
def sanitize(map) when is_map(map) do
|
||||
Map.new(map, fn {key, value} -> {to_string(key), sanitize(value)} end)
|
||||
end
|
||||
|
||||
def sanitize(list) when is_list(list), do: Enum.map(list, &sanitize/1)
|
||||
def sanitize(value) when is_boolean(value), do: value
|
||||
def sanitize(value) when is_atom(value), do: Atom.to_string(value)
|
||||
def sanitize(value), do: value
|
||||
|
||||
def sanitize_nilable(nil), do: nil
|
||||
def sanitize_nilable(value), do: sanitize(value)
|
||||
|
||||
def normalize_input(%_struct{} = struct), do: struct |> Map.from_struct() |> normalize_input()
|
||||
|
||||
def normalize_input(map) when is_map(map) do
|
||||
normalized =
|
||||
Map.new(map, fn {key, value} -> {normalize_input_key(key), normalize_input(value)} end)
|
||||
|
||||
if numeric_sequence_map?(normalized) do
|
||||
normalized
|
||||
|> Enum.sort_by(fn {key, _value} -> key end)
|
||||
|> Enum.map(fn {_key, value} -> value end)
|
||||
else
|
||||
normalized
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_input(list) when is_list(list) do
|
||||
if Enum.all?(list, &match?({key, _value} when is_integer(key) or is_float(key) or is_binary(key) or is_atom(key), &1)) do
|
||||
normalized =
|
||||
Map.new(list, fn {key, value} -> {normalize_input_key(key), normalize_input(value)} end)
|
||||
|
||||
if numeric_sequence_map?(normalized) do
|
||||
normalized
|
||||
|> Enum.sort_by(fn {key, _value} -> key end)
|
||||
|> Enum.map(fn {_key, value} -> value end)
|
||||
else
|
||||
normalized
|
||||
end
|
||||
else
|
||||
Enum.map(list, &normalize_input/1)
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_input(value) when is_atom(value), do: Atom.to_string(value)
|
||||
def normalize_input(value), do: value
|
||||
|
||||
def normalize_input_key(key) when is_integer(key), do: key
|
||||
def normalize_input_key(key) when is_float(key) and trunc(key) == key, do: trunc(key)
|
||||
|
||||
def normalize_input_key(key) when is_binary(key) do
|
||||
case Integer.parse(key) do
|
||||
{integer, ""} -> integer
|
||||
_other -> key
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_input_key(key) when is_atom(key), do: Atom.to_string(key)
|
||||
def normalize_input_key(key), do: key
|
||||
|
||||
def numeric_sequence_map?(map) when map == %{}, do: false
|
||||
|
||||
def numeric_sequence_map?(map) do
|
||||
keys = Map.keys(map)
|
||||
Enum.all?(keys, &is_integer/1) and Enum.sort(keys) == Enum.to_list(1..length(keys))
|
||||
end
|
||||
|
||||
def normalize_map(value) when is_map(value) do
|
||||
case normalize_input(value) do
|
||||
normalized when is_map(normalized) -> normalized
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_map(value) when is_list(value) do
|
||||
if Enum.all?(value, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do
|
||||
Map.new(value, fn {key, entry_value} -> {to_string(key), normalize_input(entry_value)} end)
|
||||
else
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_map(_value), do: %{}
|
||||
|
||||
def normalize_string_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
|
||||
|
||||
def normalize_string_list(value) when is_map(value) do
|
||||
value
|
||||
|> normalize_input()
|
||||
|> case do
|
||||
normalized when is_list(normalized) -> Enum.map(normalized, &to_string/1)
|
||||
_other -> []
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_string_list(_value), do: []
|
||||
|
||||
def normalize_search_filters(filters) do
|
||||
filters
|
||||
|> normalize_map()
|
||||
|> Enum.into(%{}, fn {key, value} ->
|
||||
normalized_key =
|
||||
case key do
|
||||
"start_date" -> "from"
|
||||
"end_date" -> "to"
|
||||
other -> other
|
||||
end
|
||||
|
||||
{normalized_key, value}
|
||||
end)
|
||||
end
|
||||
|
||||
def integer_or_default(value, _default) when is_integer(value), do: value
|
||||
def integer_or_default(value, _default) when is_float(value), do: trunc(value)
|
||||
def integer_or_default(_value, default), do: default
|
||||
|
||||
def string_or_nil(value) when is_binary(value), do: value
|
||||
def string_or_nil(value) when is_atom(value), do: Atom.to_string(value)
|
||||
def string_or_nil(value) when is_number(value), do: to_string(value)
|
||||
def string_or_nil(_value), do: nil
|
||||
|
||||
def truthy?(value), do: value in [true, "true", 1, 1.0, "1"]
|
||||
|
||||
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
|
||||
def blank_to_nil(nil), do: nil
|
||||
|
||||
def blank_to_nil(value) when is_binary(value) do
|
||||
if String.trim(value) == "", do: nil, else: String.trim(value)
|
||||
end
|
||||
|
||||
def blank_to_nil(value), do: value
|
||||
|
||||
def maybe_put_query(query, _key, false), do: query
|
||||
def maybe_put_query(query, _key, nil), do: query
|
||||
def maybe_put_query(query, key, value), do: Map.put(query, key, value)
|
||||
|
||||
def maybe_put_opt(opts, _key, nil), do: opts
|
||||
def maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value)
|
||||
|
||||
def maybe_put_normalized_list(attrs, key) do
|
||||
case Map.fetch(attrs, key) do
|
||||
{:ok, value} -> Map.put(attrs, key, normalize_string_list(value))
|
||||
:error -> attrs
|
||||
end
|
||||
end
|
||||
|
||||
def compare_optional(nil, _fun), do: true
|
||||
def compare_optional(value, fun) when is_function(fun, 1), do: fun.(value)
|
||||
|
||||
def parse_datetime(nil), do: nil
|
||||
def parse_datetime(value) when is_integer(value), do: DateTime.from_unix!(value, :millisecond)
|
||||
|
||||
def parse_datetime(value) when is_binary(value) do
|
||||
case DateTime.from_iso8601(value) do
|
||||
{:ok, datetime, _offset} -> datetime
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_datetime(_value), do: nil
|
||||
|
||||
def unwrap_result(result, transform \\ &sanitize/1)
|
||||
def unwrap_result({:ok, value}, transform), do: transform.(value)
|
||||
def unwrap_result({:error, _reason}, _transform), do: nil
|
||||
|
||||
def boolean_result({:ok, _value}), do: true
|
||||
def boolean_result({:error, _reason}), do: false
|
||||
|
||||
def atom_result({:ok, value}, expected_value), do: value == expected_value
|
||||
def atom_result(_result, _expected_value), do: false
|
||||
|
||||
def thumbnail_size(size) do
|
||||
case blank_to_nil(size) do
|
||||
"medium" -> :medium
|
||||
"large" -> :large
|
||||
"ai" -> :ai
|
||||
_other -> :small
|
||||
end
|
||||
end
|
||||
|
||||
def thumbnail_mime(path) do
|
||||
case Path.extname(path) do
|
||||
".jpg" -> "image/jpeg"
|
||||
".jpeg" -> "image/jpeg"
|
||||
_other -> "image/webp"
|
||||
end
|
||||
end
|
||||
|
||||
def shell_open_system_path(path) do
|
||||
{command, args} =
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> {"open", [path]}
|
||||
{:unix, _other} -> {"xdg-open", [path]}
|
||||
{:win32, _other} -> {"cmd", ["/c", "start", "", path]}
|
||||
end
|
||||
|
||||
case System.cmd(command, args, stderr_to_stdout: true) do
|
||||
{_output, 0} -> :ok
|
||||
{output, status} -> {:error, {status, String.trim(output)}}
|
||||
end
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
def shell_reveal_system_path(path) do
|
||||
{command, args} =
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> {"open", ["-R", path]}
|
||||
{:unix, _other} -> {"xdg-open", [Path.dirname(path)]}
|
||||
{:win32, _other} -> {"explorer", ["/select,", path]}
|
||||
end
|
||||
|
||||
case System.cmd(command, args, stderr_to_stdout: true) do
|
||||
{_output, 0} -> :ok
|
||||
{output, status} -> {:error, {status, String.trim(output)}}
|
||||
end
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
end
|
||||
|
||||
def zero_or_one_arg(callback) when is_function(callback, 1) do
|
||||
fn args, state ->
|
||||
decoded_args = :luerl.decode_list(args, state)
|
||||
value = callback.(normalize_input(decoded_args))
|
||||
:luerl.encode_list([sanitize(value)], state)
|
||||
end
|
||||
end
|
||||
|
||||
def one_arg(callback) when is_function(callback, 1) do
|
||||
fn args, state ->
|
||||
decoded_args = :luerl.decode_list(args, state)
|
||||
|
||||
value =
|
||||
case decoded_args do
|
||||
[first | _rest] -> callback.(normalize_input(first))
|
||||
[] -> callback.(nil)
|
||||
end
|
||||
|
||||
:luerl.encode_list([sanitize(value)], state)
|
||||
end
|
||||
end
|
||||
|
||||
def two_arg(callback) when is_function(callback, 2) do
|
||||
fn args, state ->
|
||||
decoded_args = :luerl.decode_list(args, state)
|
||||
|
||||
value =
|
||||
case decoded_args do
|
||||
[first, second | _rest] -> callback.(normalize_input(first), normalize_input(second))
|
||||
[first] -> callback.(normalize_input(first), nil)
|
||||
[] -> callback.(nil, nil)
|
||||
end
|
||||
|
||||
:luerl.encode_list([sanitize(value)], state)
|
||||
end
|
||||
end
|
||||
|
||||
def three_arg(callback) when is_function(callback, 3) do
|
||||
fn args, state ->
|
||||
decoded_args = :luerl.decode_list(args, state)
|
||||
|
||||
value =
|
||||
case decoded_args do
|
||||
[first, second, third | _rest] ->
|
||||
callback.(normalize_input(first), normalize_input(second), normalize_input(third))
|
||||
|
||||
[first, second] ->
|
||||
callback.(normalize_input(first), normalize_input(second), nil)
|
||||
|
||||
[first] ->
|
||||
callback.(normalize_input(first), nil, nil)
|
||||
|
||||
[] ->
|
||||
callback.(nil, nil, nil)
|
||||
end
|
||||
|
||||
:luerl.encode_list([sanitize(value)], state)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1072,7 +1072,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
parent = self()
|
||||
:ok = BDS.Tasks.clear_finished()
|
||||
|
||||
{:ok, _task} =
|
||||
{:ok, task} =
|
||||
BDS.Tasks.submit_task(
|
||||
"Metadata Diff",
|
||||
fn report ->
|
||||
@@ -1149,7 +1149,10 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
assert html =~ "35%"
|
||||
assert html =~ ~s(task-status-running)
|
||||
|
||||
worker_ref = Process.monitor(worker_pid)
|
||||
send(worker_pid, :finish)
|
||||
assert_receive {:DOWN, ^worker_ref, :process, _, _}, 1_000
|
||||
completed_task!(task.id)
|
||||
send(view.pid, :refresh_task_status)
|
||||
|
||||
html = render(view)
|
||||
|
||||
Reference in New Issue
Block a user