From 01e2ca02be78a5d2cdb3d207ceeae27224ca4c19 Mon Sep 17 00:00:00 2001 From: jichen Date: Wed, 5 Aug 2026 20:07:03 +0800 Subject: [PATCH] feat(console): refine console UI and signal routing --- CHANGELOG.md | 38 ++++ app/control_plane/config/dev.exs | 4 + .../ai_gateway/openai_request_options.ex | 74 +++++++ .../ai_gateway/providers/azure_openai.ex | 21 +- .../lib/ankole/ai_gateway/providers/openai.ex | 20 +- .../lib/ankole/identity_providers.ex | 77 +------ .../lib/ankole/plugins/config_secrets.ex | 95 ++++++++ .../lib/ankole/signals_gateway.ex | 11 + .../lib/ankole/signals_gateway/bindings.ex | 45 +++- .../controllers/signal_binding_controller.ex | 35 ++- app/control_plane/lib/ankole_web/router.ex | 4 + .../lib/ankole_web/schemas/console_api.ex | 34 ++- .../openai_request_options_test.exs | 76 +++++++ .../ai_gateway/responses_dispatch_test.exs | 16 +- .../ankole/plugins/config_secrets_test.exs | 44 ++++ .../identity_provider_controller_test.exs | 53 ++++- .../signal_binding_controller_test.exs | 70 ++++++ app/locales/en-US.toml | 38 +++- app/locales/zh-Hans-CN.toml | 38 +++- app/webapps/common/config-fields.tsx | 18 +- .../generated/@tanstack/react-query.gen.ts | 34 ++- app/webapps/console/api/generated/index.ts | 7 + app/webapps/console/api/generated/sdk.gen.ts | 31 ++- .../console/api/generated/types.gen.ts | 52 +++++ app/webapps/console/console-form.test.tsx | 60 ++++- app/webapps/console/console-form.tsx | 12 +- .../console/console-list-page.test.tsx | 111 ++++++++++ app/webapps/console/console-list-page.tsx | 155 +++++++++---- app/webapps/console/console-page.test.tsx | 12 + app/webapps/console/console-page.tsx | 10 +- .../console/conversation-presentation.test.ts | 40 ++++ .../console/conversation-presentation.ts | 23 ++ app/webapps/console/pages/agent-library.tsx | 2 +- app/webapps/console/pages/agents.tsx | 7 +- app/webapps/console/pages/automation-jobs.tsx | 5 +- .../console/pages/background-agent-jobs.tsx | 4 +- app/webapps/console/pages/brain-audit.tsx | 2 +- .../console/pages/brain-entry-editors.tsx | 2 +- app/webapps/console/pages/brain-sources.tsx | 8 +- app/webapps/console/pages/conversations.tsx | 82 ++++--- app/webapps/console/pages/home.tsx | 41 ++-- app/webapps/console/pages/identity.tsx | 7 +- .../pages/model-profile-editor-card.tsx | 12 +- .../pages/model-profiles-editor.test.ts | 38 ++++ .../console/pages/model-profiles-editor.tsx | 12 +- .../console/pages/principal-groups.tsx | 7 +- app/webapps/console/pages/principals.tsx | 13 +- .../pages/provider-setting-field.test.ts | 50 +++++ .../console/pages/provider-setting-field.tsx | 31 ++- app/webapps/console/pages/providers.tsx | 54 +++-- app/webapps/console/pages/settings.tsx | 13 +- app/webapps/console/pages/signals.tsx | 205 ++++++++++++------ app/webapps/console/pages/worker-envs.tsx | 7 +- app/webapps/console/pages/workers.tsx | 27 ++- .../console/state/resource-search.test.ts | 13 +- app/webapps/console/state/resource-search.ts | 5 + .../state/signal-binding-editor-model.test.ts | 12 +- .../state/signal-binding-editor-model.ts | 10 +- app/webapps/openapi/console.json | 61 +++++- docs/design-docs/SignalsGateway.md | 11 + libs/uikit/src/components/dialog.tsx | 10 +- libs/uikit/src/components/drawer.tsx | 8 +- libs/uikit/src/components/input-group.tsx | 2 +- libs/uikit/src/components/input.tsx | 2 +- libs/uikit/src/components/select.tsx | 2 +- libs/uikit/src/components/sheet.tsx | 4 +- libs/uikit/src/components/textarea.tsx | 2 +- libs/uikit/test/dialog.test.tsx | 13 +- libs/uikit/test/drawer.test.tsx | 18 ++ libs/uikit/test/input.test.tsx | 15 ++ 70 files changed, 1805 insertions(+), 370 deletions(-) create mode 100644 app/control_plane/lib/ankole/ai_gateway/openai_request_options.ex create mode 100644 app/control_plane/lib/ankole/plugins/config_secrets.ex create mode 100644 app/control_plane/test/ankole/ai_gateway/openai_request_options_test.exs create mode 100644 app/control_plane/test/ankole/plugins/config_secrets_test.exs create mode 100644 app/webapps/console/console-list-page.test.tsx create mode 100644 app/webapps/console/console-page.test.tsx create mode 100644 app/webapps/console/conversation-presentation.test.ts create mode 100644 app/webapps/console/conversation-presentation.ts create mode 100644 app/webapps/console/pages/model-profiles-editor.test.ts create mode 100644 app/webapps/console/pages/provider-setting-field.test.ts create mode 100644 libs/uikit/test/drawer.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f2ec891..75ffe7fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## Version 0.60.0 (2026-08-06) + +- Shorten the shared Drawer overlay and panel transitions to the Console's existing 150 ms and 200 ms motion rhythm. Limit the panel transition to transform and opacity, remove the full-screen backdrop blur and permanent compositor hint, and enable `will-change` only during an active swipe. + +- Give every read-only data-table row that opens another view one shared compact trailing arrow action. Apply it to Conversations, Principals, Automation Jobs, Brain Sources, and Worker file browsers; replace the Brain Source pencil and Worker text action, fill the missing Automation Job action cell, and name each icon for its resource. Keep edit rows on their existing pencil or overflow actions, and match loading placeholders to the real action size. + +- Remove the duplicate Cancel action from the shared Console resource editor footer. The header Back link is now the single way to leave these pages; dialogs, drawers, and inline editors keep Cancel when it closes or discards a real local state. + +- Use one dedicated muted-layer fill and the secondary text token for every shared read-only Console value, so immutable identifiers stay distinct from editable, disabled, and static content without using the white input fill. Keep the read-only Signal Routing rule name aligned with the adjacent adapter value in size, weight, and font family. + +- Keep stored Signal Routing and Identity Provider secrets out of edit responses and the browser DOM. Both APIs now use one plugin-owned encrypted-field implementation to omit secret fields, return only their stored paths, and preserve stored values when an edit leaves them blank. Replacement inputs opt out of password reuse, and adapter forms use one shared saved-secret prompt. Vertically center shared read-only values so immutable rule names and IDs align with adjacent controls, and do not mark an immutable rule name as required on the edit form. + +- Give managed conversations operator-facing names instead of promoting internal session keys to titles. Name Dreaming rows from their resolved channel, peer, or knowledge scope, keep the full key as secondary technical information, and apply the Console's bounded single-line truncation rule to both names and keys. + +- Make Signal Routing use normal edit and lifecycle actions without deleting its durable recovery key. Show enabled routes by default, add the Show disabled filter, and offer Edit and Disable for enabled routes or Edit and Enable for disabled routes. Add an authorized binding-detail API that restores normal connection settings, omits adapter-declared secrets, returns only their stored paths, and preserves stored secrets when an edit omits them or sends an empty value. Keep the development OpenAPI operation lookup aligned with hot-reloaded routes, regenerate the typed client, and document and test the soft-disable and safe edit contracts. + +- Explain disabled model and context-length controls inside the controls without adding a second line below either field. Both controls now prompt for a provider while they are unavailable, and the context-length column stays compact and equal in height to the adjacent fields. + +- Keep the model, provider, and context-length fields at the same visual height. Remove the duplicate provider prerequisite from inside the context field and restore the compact numeric column; the provider selector and provider-options guidance remain the single visible explanation of the dependency. + +- Keep a sticky Console action cell in the same active background as its row while its menu is open. Moving the pointer into the portalled menu no longer makes the three-dot area flash back to the lighter card layer. + +- Keep the Console row-action trigger transparent while its menu is open, so hovering a menu item does not leave a separate rectangle around the three-dot icon. Replace the fixed-width context-length column with a content-compatible flexible track so its dependency guidance stays readable without changing the guidance. + +- Keep the sticky Console table action cell in the same hover and selection state as its row instead of leaving a contrasting block at the edge. Make disabled text controls use the existing disabled text token and remove their active underline, and explain that model-profile context length becomes available after a model provider is selected. + +- Clear client-side Console resource filters immediately instead of rendering one stale deferred-query frame. Keep non-empty search input deferred, preserve the shared focus return, and prevent the toolbar, empty state, and result table from flashing during reset. + +- Keep shared Console pages inside their narrow viewport by making route and supplementary grid tracks shrink with their content. Restore focus to the row-action trigger after its confirmation dialog closes, and move focus back into the visible toolbar after an empty filtered list clears its filter. + +- Replace the free-form OpenAI and Azure OpenAI reasoning-summary and answer-detail fields with ProviderDSL-owned choices and bilingual guidance. Keep reasoning summary in the advanced group because it applies only to the Responses API. Convert the public model-profile option names to the native Responses or Chat Completions request shape before dispatch, and reject a Responses-only reasoning summary on Chat Completions instead of sending an invalid camelCase field upstream. + +- Make the Console model-provider path readable without changing its visual system or provider contracts. Show each provider kind's localized registry label in the list, let credential labels and values use the full phone width before they become paired columns, and keep raw upstream quota headers in a bounded disclosure instead of letting internal diagnostic values dominate every credential card. + +- Keep Agent model profiles understandable in both Console languages. Replace fixed profile IDs in card summaries and persistence notices with role names, mark required model inputs for assistive technology, give icon-only combobox clear actions a translated name instead of exposing an i18n key, and explain the advanced service-tier override with localized helper text instead of an unexplained English input. + +- Keep Console navigation and action state explicit for assistive technology without changing the Ankole visual system. Give the Access sibling navigation a unique bilingual landmark name, report page refresh progress on the initiating control, and let Console dialogs and sheets pass the active locale to their built-in close button instead of announcing an English-only label. + ## Version 0.59.0 (2026-08-06) - Move Codex Job collaboration wait policy out of model instructions. Keep the shared `AGENT_JOB.md` template as an empty extension point, set the native wait minimum to one minute and the default to two minutes, and leave the maximum unset so Codex keeps its default. This reduces repeated model re-entry after empty waits tracked in openai/codex#35259. diff --git a/app/control_plane/config/dev.exs b/app/control_plane/config/dev.exs index f6469d09b..e967d8615 100644 --- a/app/control_plane/config/dev.exs +++ b/app/control_plane/config/dev.exs @@ -26,6 +26,10 @@ config :ankole, AnkoleWeb.Endpoint, config :ankole, AnkoleWeb.Assets, dev_server: "http://127.0.0.1:3035" +# Phoenix reloads controller and route modules in development. Keep the OpenAPI +# operation lookup live as well, or a new route can use a stale operation cache. +config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache + config :ankole, Ankole.AIAgent.Library, internal_skills_root: Path.expand("../../../internals/skills", __DIR__), source_cache_ttl_ms: 0 diff --git a/app/control_plane/lib/ankole/ai_gateway/openai_request_options.ex b/app/control_plane/lib/ankole/ai_gateway/openai_request_options.ex new file mode 100644 index 000000000..bab155446 --- /dev/null +++ b/app/control_plane/lib/ankole/ai_gateway/openai_request_options.ex @@ -0,0 +1,74 @@ +defmodule Ankole.AIGateway.OpenAIRequestOptions do + @moduledoc """ + Maps Ankole's public OpenAI request options to the selected upstream API. + + ProviderDSL exposes stable camelCase option names to model profiles. OpenAI + Responses and Chat Completions use different wire shapes, so this module + keeps that protocol choice out of the Console and provider forms. + """ + + alias Ankole.AIGateway.UniversalAIRequest + + @reasoning_summary_values ~w(auto concise detailed) + @text_verbosity_values ~w(low medium high) + + @type endpoint :: :responses | :chat_completions + + @doc "Returns the reasoning summary values accepted by OpenAI Responses." + @spec reasoning_summary_values() :: [String.t()] + def reasoning_summary_values, do: @reasoning_summary_values + + @doc "Returns the text verbosity values accepted by OpenAI." + @spec text_verbosity_values() :: [String.t()] + def text_verbosity_values, do: @text_verbosity_values + + @doc "Writes the public options to their provider-native request locations." + @spec put_provider_options(UniversalAIRequest.t() | {:error, term()}, endpoint()) :: + UniversalAIRequest.t() | {:error, term()} + def put_provider_options({:error, _reason} = error, _endpoint), do: error + + def put_provider_options(%UniversalAIRequest{} = request, :responses) do + options = + (request.provider_options || %{}) + |> put_nested_option("reasoningSummary", "reasoning", "summary") + |> put_nested_option("textVerbosity", "text", "verbosity") + + UniversalAIRequest.put_provider_options(request, options) + end + + def put_provider_options(%UniversalAIRequest{} = request, :chat_completions) do + case Map.pop(request.provider_options || %{}, "reasoningSummary") do + {nil, options} -> + UniversalAIRequest.put_provider_options( + request, + rename_option(options, "textVerbosity", "verbosity") + ) + + {_value, _options} -> + {:error, {:unsupported_provider_option, "reasoningSummary", "chat_completions"}} + end + end + + defp put_nested_option(options, public_key, object_key, native_key) do + case Map.pop(options, public_key) do + {nil, options} -> + options + + {value, options} -> + nested = + case Map.get(options, object_key) do + nested when is_map(nested) -> Map.put(nested, native_key, value) + _value -> %{native_key => value} + end + + Map.put(options, object_key, nested) + end + end + + defp rename_option(options, public_key, native_key) do + case Map.pop(options, public_key) do + {nil, options} -> options + {value, options} -> Map.put(options, native_key, value) + end + end +end diff --git a/app/control_plane/lib/ankole/ai_gateway/providers/azure_openai.ex b/app/control_plane/lib/ankole/ai_gateway/providers/azure_openai.ex index b14ab8191..4b2554c2a 100644 --- a/app/control_plane/lib/ankole/ai_gateway/providers/azure_openai.ex +++ b/app/control_plane/lib/ankole/ai_gateway/providers/azure_openai.ex @@ -5,6 +5,7 @@ defmodule Ankole.AIGateway.Providers.AzureOpenAI do use Ankole.AIGateway.ProviderDSL + alias Ankole.AIGateway.OpenAIRequestOptions alias Ankole.AIGateway.ProviderConnectionCheck alias Ankole.AIGateway.ReasoningEffort alias Ankole.AIGateway.UniversalAIRequest @@ -33,10 +34,22 @@ defmodule Ankole.AIGateway.Providers.AzureOpenAI do scope: :request ) - setting(:reasoningSummary, scope: :request) + setting(:reasoningSummary, + type: :select, + options: OpenAIRequestOptions.reasoning_summary_values(), + scope: :request, + advanced: true + ) + setting(:serviceTier, scope: :request, advanced: true) setting(:strictJSONSchema, type: :boolean, scope: :request, advanced: true) - setting(:textVerbosity, scope: :request) + + setting(:textVerbosity, + type: :select, + options: OpenAIRequestOptions.text_verbosity_values(), + scope: :request + ) + setting(:truncation, scope: :request, advanced: true) language_model do @@ -65,6 +78,7 @@ defmodule Ankole.AIGateway.Providers.AzureOpenAI do ) |> put_auth(ctx) |> ReasoningEffort.put_provider_options(ctx, target: target_for_endpoint(endpoint_mode)) + |> OpenAIRequestOptions.put_provider_options(endpoint_target(endpoint_mode)) end end @@ -99,6 +113,9 @@ defmodule Ankole.AIGateway.Providers.AzureOpenAI do defp target_for_endpoint("responses"), do: :reasoning defp target_for_endpoint(_mode), do: :reasoning_effort + defp endpoint_target("responses"), do: :responses + defp endpoint_target(_mode), do: :chat_completions + # Azure deployments may use either bearer tokens or the legacy `api-key` # header. A credential already prefixed with `Bearer ` is treated as bearer # even when the stored auth scheme is not explicit. diff --git a/app/control_plane/lib/ankole/ai_gateway/providers/openai.ex b/app/control_plane/lib/ankole/ai_gateway/providers/openai.ex index 0034d578b..41cfac1bc 100644 --- a/app/control_plane/lib/ankole/ai_gateway/providers/openai.ex +++ b/app/control_plane/lib/ankole/ai_gateway/providers/openai.ex @@ -5,6 +5,7 @@ defmodule Ankole.AIGateway.Providers.OpenAI do use Ankole.AIGateway.ProviderDSL + alias Ankole.AIGateway.OpenAIRequestOptions alias Ankole.AIGateway.ProviderConnectionCheck alias Ankole.AIGateway.ReasoningEffort alias Ankole.AIGateway.UniversalAIRequest @@ -28,12 +29,24 @@ defmodule Ankole.AIGateway.Providers.OpenAI do scope: :request ) - setting(:reasoningSummary, scope: :request) + setting(:reasoningSummary, + type: :select, + options: OpenAIRequestOptions.reasoning_summary_values(), + scope: :request, + advanced: true + ) + setting(:promptCacheKey, scope: :request, advanced: true) setting(:promptCacheRetention, scope: :request, advanced: true) setting(:serviceTier, scope: :request, advanced: true) setting(:strictJSONSchema, type: :boolean, scope: :request, advanced: true) - setting(:textVerbosity, scope: :request) + + setting(:textVerbosity, + type: :select, + options: OpenAIRequestOptions.text_verbosity_values(), + scope: :request + ) + setting(:truncation, scope: :request, advanced: true) setting(:systemMessageMode, scope: :request, advanced: true) setting(:forceReasoning, type: :boolean, scope: :request, advanced: true) @@ -67,6 +80,7 @@ defmodule Ankole.AIGateway.Providers.OpenAI do |> openai_headers() |> UniversalAIRequest.bearer_auth() |> ReasoningEffort.put_provider_options(ctx, target: :reasoning) + |> OpenAIRequestOptions.put_provider_options(:responses) _endpoint -> prepare_sse_language_model(ctx) @@ -104,6 +118,7 @@ defmodule Ankole.AIGateway.Providers.OpenAI do |> openai_headers() |> UniversalAIRequest.bearer_auth() |> ReasoningEffort.put_provider_options(ctx, target: :reasoning_effort) + |> OpenAIRequestOptions.put_provider_options(:chat_completions) _endpoint_kind -> ctx @@ -111,6 +126,7 @@ defmodule Ankole.AIGateway.Providers.OpenAI do |> openai_headers() |> UniversalAIRequest.bearer_auth() |> ReasoningEffort.put_provider_options(ctx, target: :reasoning) + |> OpenAIRequestOptions.put_provider_options(:responses) end end diff --git a/app/control_plane/lib/ankole/identity_providers.ex b/app/control_plane/lib/ankole/identity_providers.ex index f028b87e2..05b4ddccb 100644 --- a/app/control_plane/lib/ankole/identity_providers.ex +++ b/app/control_plane/lib/ankole/identity_providers.ex @@ -8,12 +8,13 @@ defmodule Ankole.IdentityProviders do alias Ankole.IdentityProviders.Jobs.SyncProvider alias Ankole.Logging alias Ankole.Plugins + alias Ankole.Plugins.ConfigSecrets @adapter_contract_id "principals.identity_provider" @credential_check_capability "credential_check" @directory_full_sync_capability "directory_full_sync" @directory_realtime_sync_capability "directory_realtime_sync" - @secret_mask "********" + @legacy_secret_mask "********" @type adapter :: %{ adapter_id: String.t(), @@ -691,6 +692,8 @@ defmodule Ankole.IdentityProviders do defp provider_projection(provider) do with {:ok, adapter} <- fetch_adapter(provider["adapter_id"]), {:ok, config} <- provider_config(provider) do + {config, stored_secret_paths} = ConfigSecrets.redact(adapter.fields, config) + {:ok, %{ "provider_id" => provider["provider_id"], @@ -698,7 +701,8 @@ defmodule Ankole.IdentityProviders do "plugin_id" => provider["plugin_id"], "config_key" => provider["config_key"], "enabled" => provider["enabled"] != false, - "config" => mask_encrypted_config(adapter, config) + "config" => config, + "stored_secret_paths" => stored_secret_paths }} end end @@ -706,7 +710,7 @@ defmodule Ankole.IdentityProviders do defp provider_config_for_write(adapter, config_key, config) do case AppConfigure.get_by_key(config_key) do {:ok, existing} when is_map(existing) -> - {:ok, preserve_encrypted_config(adapter, config, existing)} + {:ok, ConfigSecrets.preserve(adapter.fields, config, existing, [@legacy_secret_mask])} :error -> {:ok, config} @@ -716,73 +720,6 @@ defmodule Ankole.IdentityProviders do end end - defp preserve_encrypted_config(adapter, config, existing) do - adapter - |> encrypted_field_paths() - |> Enum.reduce(config, fn path, acc -> - case {secret_placeholder?(get_path(acc, path)), get_path(existing, path)} do - {true, value} when not is_nil(value) -> put_path(acc, path, value) - _value -> acc - end - end) - end - - defp mask_encrypted_config(adapter, config) do - adapter - |> encrypted_field_paths() - |> Enum.reduce(config, fn path, acc -> - case is_nil(get_path(acc, path)) do - true -> acc - false -> put_path(acc, path, @secret_mask) - end - end) - end - - defp encrypted_field_paths(%{fields: fields}), do: encrypted_field_paths(fields) - - defp encrypted_field_paths(fields) when is_list(fields) do - fields - |> Enum.filter(&(value(&1, :encrypted) == true)) - |> Enum.map(&value(&1, :path)) - |> Enum.filter(&is_binary/1) - end - - defp encrypted_field_paths(_fields), do: [] - - defp secret_placeholder?(nil), do: true - defp secret_placeholder?(""), do: true - defp secret_placeholder?(@secret_mask), do: true - defp secret_placeholder?(_value), do: false - - defp get_path(source, path) when is_map(source) and is_binary(path) do - path - |> String.split(".") - |> Enum.reduce_while(source, fn segment, value -> - case value do - value when is_map(value) -> {:cont, Map.get(value, segment)} - _value -> {:halt, nil} - end - end) - end - - defp get_path(_source, _path), do: nil - - defp put_path(source, path, value) when is_map(source) and is_binary(path) do - do_put_path(source, String.split(path, "."), value) - end - - defp do_put_path(source, [segment], value), do: Map.put(source, segment, value) - - defp do_put_path(source, [segment | rest], value) do - child = - case Map.get(source, segment) do - child when is_map(child) -> child - _value -> %{} - end - - Map.put(source, segment, do_put_path(child, rest, value)) - end - defp sync_reason(value) when is_atom(value), do: Atom.to_string(value) defp sync_reason(value) when is_binary(value), do: value defp sync_reason(value), do: inspect(value) diff --git a/app/control_plane/lib/ankole/plugins/config_secrets.ex b/app/control_plane/lib/ankole/plugins/config_secrets.ex new file mode 100644 index 000000000..ecffe5c05 --- /dev/null +++ b/app/control_plane/lib/ankole/plugins/config_secrets.ex @@ -0,0 +1,95 @@ +defmodule Ankole.Plugins.ConfigSecrets do + @moduledoc """ + Preserves and redacts plugin-declared encrypted configuration fields. + + Plugin field paths can be nested and can use atom or string descriptor keys. + """ + + @spec preserve(list(), map(), map(), [String.t()]) :: map() + def preserve(fields, patch, current, preserved_placeholders \\ []) do + fields + |> encrypted_field_paths() + |> Enum.reduce(patch, fn path, config -> + case {placeholder?(get_path(config, path), preserved_placeholders), get_path(current, path)} do + {true, value} when not is_nil(value) -> put_path(config, path, value) + _value -> config + end + end) + end + + @spec redact(list(), map()) :: {map(), [String.t()]} + def redact(fields, config) do + fields + |> encrypted_field_paths() + |> Enum.reduce({config, []}, fn path, {redacted, stored_paths} -> + case is_nil(get_path(config, path)) do + true -> {redacted, stored_paths} + false -> {delete_path(redacted, path), [path | stored_paths]} + end + end) + |> then(fn {redacted, stored_paths} -> {redacted, Enum.reverse(stored_paths)} end) + end + + defp encrypted_field_paths(fields) when is_list(fields) do + fields + |> Enum.filter(&(field_value(&1, :encrypted) == true)) + |> Enum.map(&field_value(&1, :path)) + |> Enum.filter(&is_binary/1) + end + + defp encrypted_field_paths(_fields), do: [] + + defp field_value(field, key) when is_map(field) do + Map.get(field, key, Map.get(field, Atom.to_string(key))) + end + + defp field_value(_field, _key), do: nil + + defp placeholder?(nil, _preserved_placeholders), do: true + defp placeholder?("", _preserved_placeholders), do: true + + defp placeholder?(value, preserved_placeholders), + do: value in preserved_placeholders + + defp get_path(source, path) when is_map(source) and is_binary(path) do + path + |> String.split(".") + |> Enum.reduce_while(source, fn segment, value -> + case value do + value when is_map(value) -> {:cont, Map.get(value, segment)} + _value -> {:halt, nil} + end + end) + end + + defp get_path(_source, _path), do: nil + + defp put_path(source, path, value) when is_map(source) and is_binary(path) do + do_put_path(source, String.split(path, "."), value) + end + + defp delete_path(source, path) when is_map(source) and is_binary(path) do + do_delete_path(source, String.split(path, ".")) + end + + defp do_delete_path(source, [segment]), do: Map.delete(source, segment) + + defp do_delete_path(source, [segment | rest]) do + case Map.get(source, segment) do + child when is_map(child) -> Map.put(source, segment, do_delete_path(child, rest)) + _value -> source + end + end + + defp do_put_path(source, [segment], value), do: Map.put(source, segment, value) + + defp do_put_path(source, [segment | rest], value) do + child = + case Map.get(source, segment) do + child when is_map(child) -> child + _value -> %{} + end + + Map.put(source, segment, do_put_path(child, rest, value)) + end +end diff --git a/app/control_plane/lib/ankole/signals_gateway.ex b/app/control_plane/lib/ankole/signals_gateway.ex index f5773932f..3dc9b2944 100644 --- a/app/control_plane/lib/ankole/signals_gateway.ex +++ b/app/control_plane/lib/ankole/signals_gateway.ex @@ -108,6 +108,17 @@ defmodule Ankole.SignalsGateway do defdelegate update_binding(source_agent_uid, target_agent_uid, binding_name, attrs), to: Bindings + @doc """ + Loads one binding and its operator-editable configuration. + + Encrypted values never leave the control plane. The result lists only the + paths that already have a stored value. + """ + @spec get_binding_configuration(String.t(), String.t()) :: + {:ok, %{binding: Binding.t(), config: map(), stored_secret_paths: [String.t()]}} + | {:error, term()} + defdelegate get_binding_configuration(agent_uid, binding_name), to: Bindings + @doc """ Loads an enabled binding by route key. """ diff --git a/app/control_plane/lib/ankole/signals_gateway/bindings.ex b/app/control_plane/lib/ankole/signals_gateway/bindings.ex index 066caf69b..7fad86fa6 100644 --- a/app/control_plane/lib/ankole/signals_gateway/bindings.ex +++ b/app/control_plane/lib/ankole/signals_gateway/bindings.ex @@ -6,6 +6,7 @@ defmodule Ankole.SignalsGateway.Bindings do alias Ecto.Adapters.SQL alias Ankole.Repo alias Ankole.AppConfigure + alias Ankole.Plugins.ConfigSecrets alias Ankole.Principals alias Ankole.SignalsGateway.Actors alias Ankole.SignalsGateway.Adapters @@ -119,6 +120,34 @@ defmodule Ankole.SignalsGateway.Bindings do def update_binding(_source_agent_uid, _target_agent_uid, _binding_name, _attrs), do: {:error, :invalid_signal_binding} + @spec get_binding_configuration(String.t(), String.t()) :: + {:ok, %{binding: Binding.t(), config: map(), stored_secret_paths: [String.t()]}} + | {:error, term()} + def get_binding_configuration(agent_uid, binding_name) + when is_binary(agent_uid) and is_binary(binding_name) do + with {:ok, %{principal: principal}} <- Principals.get_agent(agent_uid), + %Binding{} = binding <- + Repo.get_by(Binding, agent_uid: principal.uid, name: binding_name), + {:ok, definition} <- Adapters.fetch(binding.adapter), + {:ok, config} <- stored_binding_config(binding) do + {config, stored_secret_paths} = ConfigSecrets.redact(definition.fields, config) + + {:ok, + %{ + binding: binding, + config: config, + stored_secret_paths: stored_secret_paths + }} + else + nil -> {:error, :binding_not_found} + {:error, :not_found} -> {:error, :agent_not_found} + {:error, _reason} = error -> error + end + end + + def get_binding_configuration(_agent_uid, _binding_name), + do: {:error, :binding_not_found} + @spec get_binding(String.t(), String.t()) :: {:ok, Binding.t()} | {:error, term()} def get_binding(agent_uid, binding_name) do case Repo.get_by(Binding, agent_uid: Utils.normalize_uid(agent_uid), name: binding_name) do @@ -355,7 +384,10 @@ defmodule Ankole.SignalsGateway.Bindings do {:ok, normalized_config} <- validate_binding_config( definition, - merge_binding_config(current_config, config_patch) + merge_binding_config( + current_config, + ConfigSecrets.preserve(definition.fields, config_patch, current_config) + ) ), :ok <- ensure_target_binding_available( @@ -418,6 +450,17 @@ defmodule Ankole.SignalsGateway.Bindings do defp binding_config_in_tx(_repo, %Binding{}), do: {:error, :binding_config_unavailable} + defp stored_binding_config(%Binding{config_ref: "app-config://" <> key}) do + case AppConfigure.get_by_key(key) do + {:ok, config} when is_map(config) -> {:ok, config} + {:ok, _config} -> {:error, :binding_config_unavailable} + :error -> {:error, :binding_config_unavailable} + {:error, _reason} = error -> error + end + end + + defp stored_binding_config(%Binding{}), do: {:error, :binding_config_unavailable} + defp merge_binding_config(current, patch) when is_map(current) and is_map(patch) do Map.merge(current, patch, fn _key, current_value, patch_value -> if is_map(current_value) and is_map(patch_value) do diff --git a/app/control_plane/lib/ankole_web/controllers/signal_binding_controller.ex b/app/control_plane/lib/ankole_web/controllers/signal_binding_controller.ex index 885cb2d90..41433cf75 100644 --- a/app/control_plane/lib/ankole_web/controllers/signal_binding_controller.ex +++ b/app/control_plane/lib/ankole_web/controllers/signal_binding_controller.ex @@ -15,6 +15,7 @@ defmodule AnkoleWeb.SignalBindingController do alias AnkoleWeb.ConsolePolicy alias AnkoleWeb.Schemas.ConsoleAPI.ErrorEnvelope alias AnkoleWeb.Schemas.ConsoleAPI.SignalAdapterListResponse + alias AnkoleWeb.Schemas.ConsoleAPI.SignalBindingDetailResponse alias AnkoleWeb.Schemas.ConsoleAPI.SignalBindingListResponse alias AnkoleWeb.Schemas.ConsoleAPI.SignalBindingResponse alias AnkoleWeb.Schemas.ConsoleAPI.SignalBindingUpdateRequest @@ -51,6 +52,21 @@ defmodule AnkoleWeb.SignalBindingController do ] ) + operation(:show, + summary: "Read one signal binding for editing", + parameters: [ + agent_uid: [in: :path, type: :string, required: true], + binding_name: [in: :path, type: :string, required: true] + ], + responses: [ + ok: {"Signal binding", "application/json", SignalBindingDetailResponse}, + unauthorized: {"Unauthorized", "application/json", ErrorEnvelope}, + forbidden: {"Forbidden", "application/json", ErrorEnvelope}, + not_found: {"Not found", "application/json", ErrorEnvelope}, + service_unavailable: {"Adapter registry unavailable", "application/json", ErrorEnvelope} + ] + ) + operation(:put_binding, summary: "Create or update one signal binding for an agent", parameters: [ @@ -85,7 +101,7 @@ defmodule AnkoleWeb.SignalBindingController do ) operation(:update_binding, - summary: "Reconfigure or move one signal binding", + summary: "Edit or move one signal binding", parameters: [ agent_uid: [in: :path, type: :string, required: true], binding_name: [in: :path, type: :string, required: true] @@ -153,6 +169,23 @@ defmodule AnkoleWeb.SignalBindingController do end end + def show(conn, params) do + with {:ok, agent_uid} <- text_param(params, "agent_uid"), + {:ok, binding_name} <- text_param(params, "binding_name"), + :ok <- + ConsolePolicy.authorize(conn, "agent:#{agent_uid}:signal_gateway_bindings", "update"), + {:ok, result} <- + SignalsGateway.get_binding_configuration(agent_uid, binding_name) do + json(conn, %{ + signal_binding: signal_binding_json(result.binding), + config: result.config, + stored_secret_paths: result.stored_secret_paths + }) + else + {:error, reason} -> error(conn, reason) + end + end + def put_binding(conn, params) do with {:ok, agent_uid} <- text_param(params, "agent_uid"), {:ok, adapter_id} <- text_param(params, "adapter_id"), diff --git a/app/control_plane/lib/ankole_web/router.ex b/app/control_plane/lib/ankole_web/router.ex index bd7f1ff2f..02dc316bb 100644 --- a/app/control_plane/lib/ankole_web/router.ex +++ b/app/control_plane/lib/ankole_web/router.ex @@ -302,6 +302,10 @@ defmodule AnkoleWeb.Router do get "/agents/:agent_uid/signal-bindings", SignalBindingController, :index + get "/agents/:agent_uid/signal-bindings/:binding_name", + SignalBindingController, + :show + put "/agents/:agent_uid/signal-bindings/:adapter_id/:binding_name", SignalBindingController, :put_binding diff --git a/app/control_plane/lib/ankole_web/schemas/console_api.ex b/app/control_plane/lib/ankole_web/schemas/console_api.ex index 150d90d7c..91930f8fa 100644 --- a/app/control_plane/lib/ankole_web/schemas/console_api.ex +++ b/app/control_plane/lib/ankole_web/schemas/console_api.ex @@ -1500,9 +1500,18 @@ defmodule AnkoleWeb.Schemas.ConsoleAPI do plugin_id: %Schema{type: :string}, config_key: %Schema{type: :string}, enabled: %Schema{type: :boolean}, - config: JSONValue + config: JSONValue, + stored_secret_paths: %Schema{type: :array, items: %Schema{type: :string}} }, - required: [:provider_id, :adapter_id, :plugin_id, :config_key, :enabled, :config], + required: [ + :provider_id, + :adapter_id, + :plugin_id, + :config_key, + :enabled, + :config, + :stored_secret_paths + ], additionalProperties: false }, struct?: false @@ -1717,6 +1726,27 @@ defmodule AnkoleWeb.Schemas.ConsoleAPI do ) end + defmodule SignalBindingDetailResponse do + @moduledoc false + + require OpenAPISpex + + OpenAPISpex.schema( + %{ + title: "SignalBindingDetailResponse", + type: :object, + properties: %{ + signal_binding: SignalBindingItem, + config: JSONValue, + stored_secret_paths: %Schema{type: :array, items: %Schema{type: :string}} + }, + required: [:signal_binding, :config, :stored_secret_paths], + additionalProperties: false + }, + struct?: false + ) + end + defmodule SignalBindingListResponse do @moduledoc false diff --git a/app/control_plane/test/ankole/ai_gateway/openai_request_options_test.exs b/app/control_plane/test/ankole/ai_gateway/openai_request_options_test.exs new file mode 100644 index 000000000..8e4879ec3 --- /dev/null +++ b/app/control_plane/test/ankole/ai_gateway/openai_request_options_test.exs @@ -0,0 +1,76 @@ +defmodule Ankole.AIGateway.OpenAIRequestOptionsTest do + use ExUnit.Case, async: true + + alias Ankole.AIGateway.OpenAIRequestOptions + alias Ankole.AIGateway.Providers + alias Ankole.AIGateway.Providers.AzureOpenAI + alias Ankole.AIGateway.Providers.OpenAI + alias Ankole.AIGateway.UniversalAIRequest + + test "provider definitions expose only accepted OpenAI output choices" do + assert setting(OpenAI, :reasoningSummary) == + {:select, ~w(auto concise detailed), true} + + assert setting(AzureOpenAI, :reasoningSummary) == + {:select, ~w(auto concise detailed), true} + + assert setting(OpenAI, :textVerbosity) == {:select, ~w(low medium high), false} + assert setting(AzureOpenAI, :textVerbosity) == {:select, ~w(low medium high), false} + + assert :ok = + Providers.validate_runtime_provider_options("openai", %{ + "reasoningSummary" => "auto", + "textVerbosity" => "high" + }) + + assert {:error, + {:provider_options, + {:invalid_value, "textVerbosity", "verbose", ["low", "medium", "high"]}}} = + Providers.validate_runtime_provider_options("openai", %{ + "textVerbosity" => "verbose" + }) + end + + test "maps Responses output controls into nested native objects" do + request = + request(%{ + "reasoning" => %{"effort" => "high"}, + "reasoningSummary" => "detailed", + "textVerbosity" => "low" + }) + + assert %UniversalAIRequest{provider_options: options} = + OpenAIRequestOptions.put_provider_options(request, :responses) + + assert options == %{ + "reasoning" => %{"effort" => "high", "summary" => "detailed"}, + "text" => %{"verbosity" => "low"} + } + end + + test "maps Chat Completions verbosity and rejects a Responses-only summary" do + assert %UniversalAIRequest{provider_options: options} = + request(%{"reasoning_effort" => "high", "textVerbosity" => "medium"}) + |> OpenAIRequestOptions.put_provider_options(:chat_completions) + + assert options == %{"reasoning_effort" => "high", "verbosity" => "medium"} + + assert {:error, {:unsupported_provider_option, "reasoningSummary", "chat_completions"}} = + request(%{"reasoningSummary" => "auto"}) + |> OpenAIRequestOptions.put_provider_options(:chat_completions) + end + + defp request(provider_options) do + %UniversalAIRequest{ + ctx: %{}, + path: "responses", + api_resolver: :openai_responses, + provider_options: provider_options + } + end + + defp setting(module, key) do + setting = Enum.find(module.provider_definition().settings, &(&1.key == key)) + {setting.type, setting.options, setting.advanced?} + end +end diff --git a/app/control_plane/test/ankole/ai_gateway/responses_dispatch_test.exs b/app/control_plane/test/ankole/ai_gateway/responses_dispatch_test.exs index b61e61bfa..791bde278 100644 --- a/app/control_plane/test/ankole/ai_gateway/responses_dispatch_test.exs +++ b/app/control_plane/test/ankole/ai_gateway/responses_dispatch_test.exs @@ -5749,7 +5749,8 @@ defmodule Ankole.AIGateway.ResponsesDispatchTest do assert {:ok, _profile} = ModelProfiles.put_model_profile(agent.uid, "primary", %{ provider_id: "azure-openai-deployment", - model: "gpt-5.5" + model: "gpt-5.5", + provider_options: %{"textVerbosity" => "low"} }) assert {:ok, %{body: body}} = @@ -5762,7 +5763,9 @@ defmodule Ankole.AIGateway.ResponsesDispatchTest do refute Map.has_key?(request.headers, "authorization") refute Map.has_key?(request.body, "model") assert request.body["reasoning_effort"] == "high" + assert request.body["verbosity"] == "low" refute Map.has_key?(request.body, "reasoningEffort") + refute Map.has_key?(request.body, "textVerbosity") assert get_in(body, ["output", Access.at(0), "content", Access.at(0), "text"]) == "azure" assert {:ok, _openai_path_provider} = @@ -5827,7 +5830,11 @@ defmodule Ankole.AIGateway.ResponsesDispatchTest do assert {:ok, _profile} = ModelProfiles.put_model_profile(agent.uid, "primary", %{ provider_id: "azure-openai-v1", - model: "gpt-5.5" + model: "gpt-5.5", + provider_options: %{ + "reasoningSummary" => "detailed", + "textVerbosity" => "high" + } }) assert {:ok, events} = @@ -5841,8 +5848,11 @@ defmodule Ankole.AIGateway.ResponsesDispatchTest do refute Map.has_key?(request.headers, "api-key") assert request.body["model"] == "gpt-5.5" assert request.body["store"] == false - assert request.body["reasoning"] == %{"effort" => "high"} + assert request.body["reasoning"] == %{"effort" => "high", "summary" => "detailed"} + assert request.body["text"] == %{"verbosity" => "high"} refute Map.has_key?(request.body, "reasoningEffort") + refute Map.has_key?(request.body, "reasoningSummary") + refute Map.has_key?(request.body, "textVerbosity") assert List.last(events)["type"] == "response.completed" assert v1_body["id"] == "resp_azure_v1" end diff --git a/app/control_plane/test/ankole/plugins/config_secrets_test.exs b/app/control_plane/test/ankole/plugins/config_secrets_test.exs new file mode 100644 index 000000000..c2b2f7314 --- /dev/null +++ b/app/control_plane/test/ankole/plugins/config_secrets_test.exs @@ -0,0 +1,44 @@ +defmodule Ankole.Plugins.ConfigSecretsTest do + use ExUnit.Case, async: true + + alias Ankole.Plugins.ConfigSecrets + + @fields [ + %{path: "clientId", encrypted: false}, + %{path: "clientSecret", encrypted: true}, + %{"path" => "nested.token", "encrypted" => true} + ] + + test "redact removes only stored encrypted fields and reports their paths" do + config = %{ + "clientId" => "public-id", + "clientSecret" => "secret", + "nested" => %{"token" => "nested-secret", "region" => "cn"} + } + + assert ConfigSecrets.redact(@fields, config) == + {%{"clientId" => "public-id", "nested" => %{"region" => "cn"}}, + ["clientSecret", "nested.token"]} + end + + test "preserve restores omitted and blank secrets but accepts replacements" do + current = %{"clientSecret" => "old-secret", "nested" => %{"token" => "old-token"}} + patch = %{"clientSecret" => "new-secret", "nested" => %{"token" => ""}} + + assert ConfigSecrets.preserve(@fields, patch, current) == %{ + "clientSecret" => "new-secret", + "nested" => %{"token" => "old-token"} + } + + assert ConfigSecrets.preserve(@fields, %{}, current) == current + end + + test "preserve supports an explicit legacy placeholder without changing the default" do + current = %{"clientSecret" => "old-secret"} + patch = %{"clientSecret" => "********"} + + assert ConfigSecrets.preserve(@fields, patch, current) == patch + + assert ConfigSecrets.preserve(@fields, patch, current, ["********"]) == current + end +end diff --git a/app/control_plane/test/ankole_web/controllers/identity_provider_controller_test.exs b/app/control_plane/test/ankole_web/controllers/identity_provider_controller_test.exs index 9077f2d0f..4e646815f 100644 --- a/app/control_plane/test/ankole_web/controllers/identity_provider_controller_test.exs +++ b/app/control_plane/test/ankole_web/controllers/identity_provider_controller_test.exs @@ -129,10 +129,13 @@ defmodule AnkoleWeb.IdentityProviderControllerTest do "adapter_id" => "lark", "config_key" => "principals.identity_providers.lark.lark-main", "enabled" => true, - "config" => %{"appID" => "cli_identity_console", "appSecret" => "********"} + "config" => %{"appID" => "cli_identity_console"}, + "stored_secret_paths" => ["appSecret"] } } = json_response(conn, 200) + refute conn.resp_body =~ "secret-console" + assert_enqueued( worker: SyncProvider, args: %{"provider_id" => "lark-main", "reason" => "provider_saved", "source" => "console"} @@ -145,12 +148,17 @@ defmodule AnkoleWeb.IdentityProviderControllerTest do assert %{ "identity_providers" => [ - %{"provider_id" => "lark-main", "config" => listed_config} + %{ + "provider_id" => "lark-main", + "config" => listed_config, + "stored_secret_paths" => ["appSecret"] + } ] } = json_response(conn, 200) - assert listed_config["appSecret"] == "********" + refute Map.has_key?(listed_config, "appSecret") + refute conn.resp_body =~ "secret-console" conn = conn @@ -170,7 +178,7 @@ defmodule AnkoleWeb.IdentityProviderControllerTest do ) end - test "saving an existing provider preserves masked secret fields", %{conn: conn} do + test "saving an existing provider preserves blank secret fields", %{conn: conn} do conn = conn |> bearer_conn() @@ -183,7 +191,12 @@ defmodule AnkoleWeb.IdentityProviderControllerTest do } }) - assert %{"identity_provider" => %{"config" => %{"appSecret" => "********"}}} = + assert %{ + "identity_provider" => %{ + "config" => %{"appID" => "cli_identity_console"}, + "stored_secret_paths" => ["appSecret"] + } + } = json_response(conn, 200) conn = @@ -194,17 +207,43 @@ defmodule AnkoleWeb.IdentityProviderControllerTest do "enabled" => true, "config" => %{ "appID" => "cli_identity_console_renamed", - "appSecret" => "********", + "appSecret" => "", "sync" => %{"contacts" => true} } }) - assert %{"identity_provider" => %{"config" => %{"appSecret" => "********"}}} = + assert %{ + "identity_provider" => %{ + "config" => %{"appID" => "cli_identity_console_renamed"}, + "stored_secret_paths" => ["appSecret"] + } + } = json_response(conn, 200) + refute conn.resp_body =~ "secret-console" + assert {:ok, config} = AppConfigure.get_by_key(LarkConfig.identity_config_key("lark-main")) assert config["appID"] == "cli_identity_console_renamed" assert config["appSecret"] == "secret-console" + + conn = + conn + |> recycle_api() + |> put(~p"/api/v1/identity-providers/lark-main", %{ + "adapter_id" => "lark", + "enabled" => true, + "config" => %{ + "appID" => "cli_identity_console_renamed", + "appSecret" => "********", + "sync" => %{"contacts" => true} + } + }) + + assert %{"identity_provider" => %{"stored_secret_paths" => ["appSecret"]}} = + json_response(conn, 200) + + assert {:ok, config} = AppConfigure.get_by_key(LarkConfig.identity_config_key("lark-main")) + assert config["appSecret"] == "secret-console" end test "manual full sync returns a clear error when directory sync is disabled", %{conn: conn} do diff --git a/app/control_plane/test/ankole_web/controllers/signal_binding_controller_test.exs b/app/control_plane/test/ankole_web/controllers/signal_binding_controller_test.exs index 15657b13e..4c131ad7b 100644 --- a/app/control_plane/test/ankole_web/controllers/signal_binding_controller_test.exs +++ b/app/control_plane/test/ankole_web/controllers/signal_binding_controller_test.exs @@ -101,6 +101,76 @@ defmodule AnkoleWeb.SignalBindingControllerTest do assert {:error, :binding_disabled} = SignalsGateway.get_binding(agent.uid, "lark-main") end + test "admin edits a disabled binding without exposing or replacing its secret", %{conn: conn} do + %{principal: agent} = agent_fixture() + + original_config = + lark_config("editable") + |> Map.put("domain", "feishu") + + conn = + conn + |> bearer_conn() + |> put_binding(agent.uid, "lark", "lark-main", original_config) + + assert response(conn, 200) + + conn = + conn + |> recycle_api() + |> delete(~p"/api/v1/agents/#{agent.uid}/signal-bindings/lark-main") + + assert %{"signal_binding" => %{"enabled" => false}} = json_response(conn, 200) + + conn = + conn + |> recycle_api() + |> get(~p"/api/v1/agents/#{agent.uid}/signal-bindings/lark-main") + + assert %{ + "signal_binding" => %{ + "agent_uid" => agent_uid, + "name" => "lark-main", + "adapter" => "lark", + "enabled" => false + }, + "config" => %{ + "appID" => "cli_editable", + "domain" => "feishu", + "platformSubjectNamespace" => "namespace-editable", + "userName" => "Bot editable" + }, + "stored_secret_paths" => ["appSecret"] + } = json_response(conn, 200) + + assert agent_uid == agent.uid + refute conn.resp_body =~ original_config["appSecret"] + + conn = + conn + |> recycle_api() + |> patch(~p"/api/v1/agents/#{agent.uid}/signal-bindings/lark-main", %{ + "target_agent_uid" => agent.uid, + "config" => %{"appSecret" => "", "domain" => "lark"}, + "group_message_mode" => "observe_all", + "confidential_memory" => true + }) + + assert %{ + "signal_binding" => %{ + "enabled" => true, + "confidential_memory" => true, + "unaddressed_group_message_policy" => "record_only" + } + } = json_response(conn, 200) + + assert {:ok, stored_config} = + LarkConfig.load_chat_config_ref(LarkConfig.chat_config_key(agent.uid)) + + assert stored_config["appSecret"] == original_config["appSecret"] + assert stored_config["domain"] == "lark" + end + test "unknown signal adapter remains a 404", %{conn: conn} do %{principal: agent} = agent_fixture() diff --git a/app/locales/en-US.toml b/app/locales/en-US.toml index 3fc080d7e..939e8ad6d 100644 --- a/app/locales/en-US.toml +++ b/app/locales/en-US.toml @@ -93,6 +93,7 @@ title = "Sign in to Ankole" advanced_settings = "Advanced settings" back = "Back" cancel = "Cancel" +clear = "Clear" close = "Close" continue = "Continue" delete = "Delete" @@ -118,9 +119,11 @@ required = "(required)" retry = "Retry" save = "Save" save_disabled = "There are no changes to save." +secret_saved_placeholder = "Saved — enter a new value to replace" select_empty = "No options are available" select_no_agents = "No Agents are available" theme_dark = "Use dark theme" +view_details_for = "View details for {$name}" theme_light = "Use light theme" unset = "Unset" @@ -174,9 +177,11 @@ saved_with_unsaved_changes = "Saved {$kind}. Newer edits remain unsaved." title = "MISSION / SOUL / DESIGN" [console.aria] +access_sections = "Access sections" hide_secret = "Hide value" open_navigation = "Open navigation" refresh = "Refresh" +refreshing = "Refreshing" reveal_secret = "Reveal value" sections = "Console sections" skip_to_content = "Skip to content" @@ -376,11 +381,11 @@ saved = "Grant saved." [console.models] add_custom_profile = "Add custom model profile" -background_agent_jobs = "Background Agent Job default model" clear = "Clear" cleared = "Profile {$profile} cleared." cleared_with_unsaved_changes = "Profile {$profile} cleared. Newer edits remain unsaved." coding_description = "Used for tasks that an Agent creates and continues in the background." +coding_label = "Background Agent Job default model" context = "Context" custom_deleted = "Custom profile {$profile} deleted." custom_description = "Create named LLM profiles that this Agent can select for one turn or one Background Agent Job." @@ -393,11 +398,15 @@ custom_name_reserved = "This name is reserved for a fixed model profile." custom_profile_description = "Description" custom_profile_description_required = "Description is required." custom_title = "Custom model profiles" -description = "Select the providers and models for conversations, Background Agent Jobs, and other capabilities. Primary, light, and heavy are required." +description = "Select the providers and models for conversations, Background Agent Jobs, and other capabilities. The primary, lightweight, and complex-work profiles are required." embedding_description = "Used to create vector representations for memories, episodes, and queries." +embedding_label = "Embedding model" heavy_description = "Used for complex work. It is also the fallback when Background Agent Jobs is not configured." +heavy_label = "Complex work model" image_generate_description = "Used when the main model does not support native image generation." +image_generate_label = "Image generation model" light_description = "Used for lightweight work such as summaries, memory curation, and context compaction." +light_label = "Lightweight model" model = "Model" model_empty = "No known models. Enter a model ID." model_placeholder = "Select or enter a model" @@ -405,8 +414,10 @@ model_use = "Use {$model}" new_custom_profile = "New custom profile" not_configured = "Not configured" primary_description = "Used for normal conversations and Agent work." +primary_label = "Primary model" provider = "Provider" provider_definition_unavailable = "The selected provider definition is unavailable. Refresh the page and try again." +provider_required_placeholder = "Select a provider" provider_empty = "No provider supports this capability" provider_options = "Provider options" provider_options_empty = "This provider has no profile-level options." @@ -414,14 +425,18 @@ provider_options_select = "Select a provider to configure its options." provider_options_unavailable = "Add a compatible provider to configure its options here." provider_placeholder = "Select a provider" rerank_description = "Used to reorder memory search results. It is not required when reranking is disabled." +rerank_label = "Reranking model" required = "Required" saved = "Profile {$profile} saved." saved_with_unsaved_changes = "Profile {$profile} saved. Newer edits remain unsaved." title = "Model profiles" unsaved = "Unsaved changes" vision_fallback_description = "Used when the selected turn model cannot accept images. Choose a model that supports image input." +vision_fallback_label = "Vision fallback model" web_fetch_description = "Used to read web page content and metadata." +web_fetch_label = "Web fetch model" web_search_description = "Used to search the web and return structured results." +web_search_label = "Web search model" [console.nav] home = "Home" @@ -817,7 +832,6 @@ skill_count = "{$count} Skills" skills = "Skills" title = "Agent Library" view_details = "View details" -view_details_for = "View details for {$name}" [console.providers] access_token = "Access token" @@ -879,6 +893,12 @@ priority_value = "priority {$priority}" provider = "Provider" reasoning_effort = "Reasoning effort" reasoning_effort_hint = "Controls how much reasoning the model uses before it answers. Higher values can help with complex work but take longer." +reasoning_summary = "Reasoning summary" +reasoning_summary_hint = "Controls the reasoning summary returned by the Responses API. Model support varies. Leave unset to use the provider default." +service_tier = "Service tier" +service_tier_hint = "Overrides the service tier for this model profile. Values such as priority depend on the provider, account, and model. Leave blank to use the default." +text_verbosity = "Answer detail" +text_verbosity_hint = "Sets the default level of detail for answers. Task-specific instructions still control the required length and structure." provider_id = "Provider ID" provider_id_hint = "Lowercase identifier used by model bindings. It cannot be changed later." provider_id_required = "Provider ID is required." @@ -1042,13 +1062,15 @@ binding_name = "Rule name" binding_name_required = "Rule name is required." confidential_memory = "Confidential group memory" confidential_memory_hint = "When enabled, group messages use a channel-only memory store instead of the instance-wide shared store. The next group turn starts a new conversation." -delete_description = "Delete routing rule {$name}?" -delete_title = "Delete routing rule" -deleted = "Routing rule {$name} deleted." +disabled = "Routing rule {$name} disabled." description = "Route messages and events from Channel Providers and other signal sources to an Agent." +edit_hint = "View and edit this routing rule. Stored secrets are not loaded into the browser; leave a secret field blank to keep it." editor_description = "Set the Channel Provider and target Agent for this routing rule." +empty_active_description = "Turn on Show disabled to view and restore previous routes, or create a routing rule." +empty_active_title = "No enabled routing rules" empty_description = "Create a routing rule to send messages to this Agent." empty_title = "No routing rules" +enabled = "Routing rule {$name} enabled." group_message_mode_invalid = "Group message mode is invalid." memory_confidential = "Channel only" memory_scope = "Memory scope" @@ -1059,8 +1081,6 @@ policy = "Policy" policy_ignore = "Ignore unaddressed" policy_may_intervene = "May intervene" policy_record_only = "Record only" -reconfigure = "Reconfigure" -reconfigure_hint = "Saving keeps unchanged Channel Provider settings and sends future signals to a new Agent if you select one." saved = "Routing rule {$name} saved." search = "Search routing rules" search_placeholder = "Search name, adapter, policy, or status" @@ -1072,6 +1092,7 @@ section_behavior_hint = "Control which group messages the Agent receives and whe section_connection = "Channel connection" section_connection_hint = "Enter the credentials required by the selected Channel Provider. Optional operational settings stay under Advanced connection settings." select_agent = "Select an Agent" +show_disabled = "Show disabled" state = "State" target_agent = "Target Agent" target_agent_required = "Target Agent is required." @@ -1196,6 +1217,7 @@ updated_at = "Updated" [console.workers] active_turns = "Active turns" browse_files = "Browse files" +browse_files_for = "Browse files on {$name}" deployment_guide = "Choose a deployment method and get the start command" deployment_options = "Supports Docker Compose, Kubernetes, and source installations." description = "Agent computer worker registry. Each worker owns its own filesystem." diff --git a/app/locales/zh-Hans-CN.toml b/app/locales/zh-Hans-CN.toml index 026ce3cb4..f3bf5c1d2 100644 --- a/app/locales/zh-Hans-CN.toml +++ b/app/locales/zh-Hans-CN.toml @@ -94,6 +94,7 @@ title = "登录 Ankole" advanced_settings = "高级设置" back = "返回" cancel = "取消" +clear = "清除" close = "关闭" continue = "继续" delete = "删除" @@ -119,9 +120,11 @@ required = "(必填)" retry = "重试" save = "保存" save_disabled = "当前没有可保存的修改。" +secret_saved_placeholder = "已保存;输入新值以替换" select_empty = "暂无可用选项" select_no_agents = "暂无可用 Agent" theme_dark = "切换到深色主题" +view_details_for = "查看 {$name} 的详情" theme_light = "切换到浅色主题" unset = "未设置" @@ -175,9 +178,11 @@ saved_with_unsaved_changes = "已保存 {$kind},较新的编辑仍未保存。 title = "MISSION / SOUL / DESIGN" [console.aria] +access_sections = "访问控制分区" hide_secret = "隐藏内容" open_navigation = "打开导航" refresh = "刷新" +refreshing = "正在刷新" reveal_secret = "显示内容" sections = "控制台分区" skip_to_content = "跳到主要内容" @@ -377,11 +382,11 @@ saved = "已保存授权。" [console.models] add_custom_profile = "添加自定义模型档案" -background_agent_jobs = "后台 Agent 任务默认模型" clear = "清除" cleared = "已清除“{$profile}”档案。" cleared_with_unsaved_changes = "已清除“{$profile}”档案;较新的编辑仍未保存。" coding_description = "用于由 Agent 创建并持续在后台执行的任务。" +coding_label = "后台 Agent 任务默认模型" context = "上下文长度" custom_deleted = "已删除自定义模型档案“{$profile}”。" custom_description = "创建命名的 LLM 档案,供此 Agent 的单次 turn 或单个后台 Agent 任务显式选择。" @@ -394,11 +399,15 @@ custom_name_reserved = "该名称已被固定模型档案占用。" custom_profile_description = "说明" custom_profile_description_required = "说明为必填项。" custom_title = "自定义模型档案" -description = "配置普通对话、后台 Agent 任务和其他能力使用的模型提供商和模型。primary、light、heavy 为必填。" +description = "配置普通对话、后台 Agent 任务和其他能力使用的模型提供商和模型。主模型、轻量模型和复杂任务模型为必填。" embedding_description = "用于生成记忆、片段和查询的向量表示。" +embedding_label = "向量嵌入模型" heavy_description = "用于复杂任务;未配置“后台 Agent 任务”时也作为兜底模型。" +heavy_label = "复杂任务模型" image_generate_description = "主模型不支持原生图像生成时使用。" +image_generate_label = "图像生成模型" light_description = "用于摘要、记忆整理和上下文压缩等轻量任务。" +light_label = "轻量模型" model = "模型" model_empty = "没有已知模型,请直接输入模型 ID。" model_placeholder = "选择或输入模型" @@ -406,8 +415,10 @@ model_use = "使用 {$model}" new_custom_profile = "新自定义模型档案" not_configured = "未配置" primary_description = "用于日常对话和普通 Agent 工作。" +primary_label = "主模型" provider = "模型提供商" provider_definition_unavailable = "无法读取所选模型提供商的定义,请刷新页面后重试。" +provider_required_placeholder = "先选模型提供商" provider_empty = "没有支持此能力的模型提供商" provider_options = "模型提供商选项" provider_options_empty = "这个模型提供商没有档案级选项。" @@ -415,14 +426,18 @@ provider_options_select = "选择模型提供商后配置对应选项。" provider_options_unavailable = "添加支持此能力的模型提供商后,可在这里配置对应选项。" provider_placeholder = "选择模型提供商" rerank_description = "用于对记忆搜索结果进行二次排序;未启用重排时无需配置。" +rerank_label = "检索重排模型" required = "必填" saved = "已保存“{$profile}”档案。" saved_with_unsaved_changes = "已保存“{$profile}”档案;较新的编辑仍未保存。" title = "模型档案" unsaved = "有未保存修改" vision_fallback_description = "当前 turn 的模型无法接收图片时使用;请选择支持图片输入的模型。" +vision_fallback_label = "图片输入兜底模型" web_fetch_description = "用于读取网页正文和元数据。" +web_fetch_label = "网页读取模型" web_search_description = "用于联网搜索并返回结构化搜索结果。" +web_search_label = "联网搜索模型" [console.nav] home = "首页" @@ -818,7 +833,6 @@ skill_count = "{$count} 个 Skill" skills = "Skills" title = "Agent 能力库" view_details = "查看详情" -view_details_for = "查看 {$name} 的详情" [console.providers] access_token = "Access Token" @@ -880,6 +894,12 @@ priority_value = "优先级 {$priority}" provider = "模型提供商" reasoning_effort = "推理强度" reasoning_effort_hint = "控制模型回答前投入的推理计算量。强度越高通常越适合复杂任务,但响应会更慢。" +reasoning_summary = "推理摘要" +reasoning_summary_hint = "控制 Responses API 返回的推理摘要;可用程度取决于模型。留空则使用模型提供商的默认值。" +service_tier = "服务等级" +service_tier_hint = "覆盖此模型档案的服务等级。priority 等可用值取决于模型提供商、账号和模型;留空则使用默认值。" +text_verbosity = "回答详略" +text_verbosity_hint = "设置回答的默认详细程度;具体长度和结构仍由任务要求决定。" provider_id = "模型提供商 ID" provider_id_hint = "模型绑定使用的小写标识符,创建后不可修改。" provider_id_required = "模型提供商 ID 为必填项。" @@ -1043,13 +1063,15 @@ binding_name = "规则名称" binding_name_required = "规则名称为必填项。" confidential_memory = "保密群聊记忆" confidential_memory_hint = "启用后,群消息写入该频道专属记忆库,不进入整个实例共用的共享库。群内下一轮会自动开始新会话。" -delete_description = "删除路由规则 {$name}?" -delete_title = "删除路由规则" -deleted = "已删除路由规则 {$name}。" +disabled = "已停用路由规则 {$name}。" description = "把聊天渠道和其他信号源收到的消息或事件交给指定 Agent。" +edit_hint = "查看并修改现有路由。已保存的密钥不会加载到浏览器;密钥字段留空会保留原值。" editor_description = "设置这条路由规则使用的聊天渠道和目标 Agent。" +empty_active_description = "开启“显示已停用”可查看和恢复历史路由,或新建一条路由规则。" +empty_active_title = "暂无启用的路由规则" empty_description = "创建一条路由规则,把消息交给这个 Agent。" empty_title = "暂无路由规则" +enabled = "已启用路由规则 {$name}。" group_message_mode_invalid = "群消息模式无效。" memory_confidential = "仅本频道" memory_scope = "记忆范围" @@ -1060,8 +1082,6 @@ policy = "策略" policy_ignore = "忽略未点名消息" policy_may_intervene = "可主动介入" policy_record_only = "仅记录" -reconfigure = "重新配置" -reconfigure_hint = "保存时会保留未修改的聊天渠道配置;选择其他 Agent 后,后续信号将交给新的 Agent。" saved = "已保存路由规则 {$name}。" search = "搜索路由规则" search_placeholder = "搜索名称、适配器、策略或状态" @@ -1073,6 +1093,7 @@ section_behavior_hint = "设置 Agent 接收哪些群消息,以及这些消息 section_connection = "渠道连接" section_connection_hint = "填写所选聊天渠道要求的凭据;可选的运行设置收纳在“高级连接设置”中。" select_agent = "选择一个 Agent" +show_disabled = "显示已停用" state = "状态" target_agent = "目标 Agent" target_agent_required = "必须选择目标 Agent。" @@ -1197,6 +1218,7 @@ updated_at = "更新时间" [console.workers] active_turns = "活跃轮次" browse_files = "浏览文件" +browse_files_for = "浏览 {$name} 的文件" deployment_guide = "选择部署方式并查看启动命令" deployment_options = "支持 Docker Compose、Kubernetes 和源码安装。" description = "Agent Computer 工作节点注册表。每个工作节点拥有独立文件系统。" diff --git a/app/webapps/common/config-fields.tsx b/app/webapps/common/config-fields.tsx index 4cf706609..f3f328c00 100644 --- a/app/webapps/common/config-fields.tsx +++ b/app/webapps/common/config-fields.tsx @@ -45,6 +45,7 @@ export type ConfigFieldDefinition = { advanced?: boolean default?: unknown description?: LocalizedText + encrypted?: boolean label?: LocalizedText max?: number min?: number @@ -63,6 +64,8 @@ export function ConfigFields({ fields, locale, onChange, + preservedSecretPaths = [], + preservedSecretPlaceholder, showAdvancedCount = true }: { advancedLabel?: string @@ -71,6 +74,8 @@ export function ConfigFields({ fields: ConfigFieldDefinition[] locale: string onChange(path: string, value: unknown, field: ConfigFieldDefinition): void + preservedSecretPaths?: readonly string[] + preservedSecretPlaceholder?: string showAdvancedCount?: boolean }) { const basicFields = fields.filter(field => !field.advanced) @@ -101,12 +106,15 @@ export function ConfigFields({ ) function renderConfigField(field: ConfigFieldDefinition) { + const preservedSecret = field.encrypted === true && preservedSecretPaths.includes(field.path) + return ( onChange(field.path, value, field)} /> @@ -118,12 +126,14 @@ export function ConfigField({ field, locale, onChange, + placeholder, required = field.required === true, value }: { field: ConfigFieldDefinition locale: string onChange(value: unknown): void + placeholder?: string required?: boolean value: unknown }) { @@ -135,6 +145,7 @@ export function ConfigField({ const inputRef = useRef(null) const textareaRef = useRef(null) const [showValidationError, setShowValidationError] = useState(false) + const secret = field.encrypted === true || field.type === 'secret' const validationError = required || !field.requiredWhen?.length ? configFieldValidationMessage(field, value, locale) : undefined const describedBy = [ @@ -246,10 +257,13 @@ export function ConfigField({ ref={inputRef} aria-describedby={describedBy || undefined} aria-invalid={showValidationError && validationError ? true : undefined} + autoComplete={secret ? 'new-password' : undefined} max={field.max} min={field.min} + placeholder={placeholder} required={required} - type={field.type === 'secret' ? 'password' : field.type === 'integer' ? 'number' : 'text'} + spellCheck={secret ? false : undefined} + type={secret ? 'password' : field.type === 'integer' ? 'number' : 'text'} value={value == null ? '' : String(value)} onChange={event => onChange(field.type === 'integer' ? Number(event.target.value) : event.target.value)} onBlur={() => setShowValidationError(Boolean(validationError))} diff --git a/app/webapps/console/api/generated/@tanstack/react-query.gen.ts b/app/webapps/console/api/generated/@tanstack/react-query.gen.ts index 35753592c..96ddd8d27 100644 --- a/app/webapps/console/api/generated/@tanstack/react-query.gen.ts +++ b/app/webapps/console/api/generated/@tanstack/react-query.gen.ts @@ -128,6 +128,7 @@ import { ankoleWebSignalBindingControllerIndex, ankoleWebSignalBindingControllerPutBinding, ankoleWebSignalBindingControllerPutChannelStandingOrders, + ankoleWebSignalBindingControllerShow, ankoleWebSignalBindingControllerShowChannelStandingOrders, ankoleWebSignalBindingControllerUpdateBinding, ankoleWebWebhookEndpointControllerDelete, @@ -498,6 +499,9 @@ import type { AnkoleWebSignalBindingControllerShowChannelStandingOrdersData, AnkoleWebSignalBindingControllerShowChannelStandingOrdersError, AnkoleWebSignalBindingControllerShowChannelStandingOrdersResponse, + AnkoleWebSignalBindingControllerShowData, + AnkoleWebSignalBindingControllerShowError, + AnkoleWebSignalBindingControllerShowResponse, AnkoleWebSignalBindingControllerUpdateBindingData, AnkoleWebSignalBindingControllerUpdateBindingError, AnkoleWebSignalBindingControllerUpdateBindingResponse, @@ -2983,8 +2987,36 @@ export const ankoleWebSignalBindingControllerDeleteMutation = ( return mutationOptions } +export const ankoleWebSignalBindingControllerShowQueryKey = ( + options: Options +) => createQueryKey('ankoleWebSignalBindingControllerShow', options) + +/** + * Read one signal binding for editing + */ +export const ankoleWebSignalBindingControllerShowOptions = ( + options: Options +) => + queryOptions< + AnkoleWebSignalBindingControllerShowResponse, + AnkoleWebSignalBindingControllerShowError, + AnkoleWebSignalBindingControllerShowResponse, + ReturnType + >({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await ankoleWebSignalBindingControllerShow({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }) + return data + }, + queryKey: ankoleWebSignalBindingControllerShowQueryKey(options) + }) + /** - * Reconfigure or move one signal binding + * Edit or move one signal binding */ export const ankoleWebSignalBindingControllerUpdateBindingMutation = ( options?: Partial> diff --git a/app/webapps/console/api/generated/index.ts b/app/webapps/console/api/generated/index.ts index 94f8f0196..f3b4fd274 100644 --- a/app/webapps/console/api/generated/index.ts +++ b/app/webapps/console/api/generated/index.ts @@ -119,6 +119,7 @@ export { ankoleWebSignalBindingControllerIndex, ankoleWebSignalBindingControllerPutBinding, ankoleWebSignalBindingControllerPutChannelStandingOrders, + ankoleWebSignalBindingControllerShow, ankoleWebSignalBindingControllerShowChannelStandingOrders, ankoleWebSignalBindingControllerUpdateBinding, ankoleWebWebhookEndpointControllerDelete, @@ -762,6 +763,11 @@ export type { AnkoleWebSignalBindingControllerShowChannelStandingOrdersErrors, AnkoleWebSignalBindingControllerShowChannelStandingOrdersResponse, AnkoleWebSignalBindingControllerShowChannelStandingOrdersResponses, + AnkoleWebSignalBindingControllerShowData, + AnkoleWebSignalBindingControllerShowError, + AnkoleWebSignalBindingControllerShowErrors, + AnkoleWebSignalBindingControllerShowResponse, + AnkoleWebSignalBindingControllerShowResponses, AnkoleWebSignalBindingControllerUpdateBindingData, AnkoleWebSignalBindingControllerUpdateBindingError, AnkoleWebSignalBindingControllerUpdateBindingErrors, @@ -950,6 +956,7 @@ export type { SignalAdapterFieldOption, SignalAdapterItem, SignalAdapterListResponse, + SignalBindingDetailResponse, SignalBindingItem, SignalBindingListResponse, SignalBindingResponse, diff --git a/app/webapps/console/api/generated/sdk.gen.ts b/app/webapps/console/api/generated/sdk.gen.ts index 7459d3b34..a80fba408 100644 --- a/app/webapps/console/api/generated/sdk.gen.ts +++ b/app/webapps/console/api/generated/sdk.gen.ts @@ -359,6 +359,9 @@ import type { AnkoleWebSignalBindingControllerShowChannelStandingOrdersData, AnkoleWebSignalBindingControllerShowChannelStandingOrdersErrors, AnkoleWebSignalBindingControllerShowChannelStandingOrdersResponses, + AnkoleWebSignalBindingControllerShowData, + AnkoleWebSignalBindingControllerShowErrors, + AnkoleWebSignalBindingControllerShowResponses, AnkoleWebSignalBindingControllerUpdateBindingData, AnkoleWebSignalBindingControllerUpdateBindingErrors, AnkoleWebSignalBindingControllerUpdateBindingResponses, @@ -2632,7 +2635,33 @@ export const ankoleWebSignalBindingControllerDelete = ( + options: Options +): RequestResult< + AnkoleWebSignalBindingControllerShowResponses, + AnkoleWebSignalBindingControllerShowErrors, + ThrowOnError +> => + (options.client ?? client).get< + AnkoleWebSignalBindingControllerShowResponses, + AnkoleWebSignalBindingControllerShowErrors, + ThrowOnError + >({ + security: [ + { + key: 'consoleBearer', + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/agents/{agent_uid}/signal-bindings/{binding_name}', + ...options + }) + +/** + * Edit or move one signal binding */ export const ankoleWebSignalBindingControllerUpdateBinding = ( options: Options diff --git a/app/webapps/console/api/generated/types.gen.ts b/app/webapps/console/api/generated/types.gen.ts index 4b858bb9c..d303804d0 100644 --- a/app/webapps/console/api/generated/types.gen.ts +++ b/app/webapps/console/api/generated/types.gen.ts @@ -583,6 +583,7 @@ export type IdentityProviderItem = { enabled: boolean plugin_id: string provider_id: string + stored_secret_paths: Array } /** @@ -1605,6 +1606,15 @@ export type AgentLibrarySkillOverlaysResponse = { skill_overlays: Array } +/** + * SignalBindingDetailResponse + */ +export type SignalBindingDetailResponse = { + config: JsonValue + signal_binding: SignalBindingItem + stored_secret_paths: Array +} + /** * BrainSourceEntryResponse */ @@ -5195,6 +5205,48 @@ export type AnkoleWebSignalBindingControllerDeleteResponses = { export type AnkoleWebSignalBindingControllerDeleteResponse = AnkoleWebSignalBindingControllerDeleteResponses[keyof AnkoleWebSignalBindingControllerDeleteResponses] +export type AnkoleWebSignalBindingControllerShowData = { + body?: never + path: { + agent_uid: string + binding_name: string + } + query?: never + url: '/api/v1/agents/{agent_uid}/signal-bindings/{binding_name}' +} + +export type AnkoleWebSignalBindingControllerShowErrors = { + /** + * Unauthorized + */ + 401: ConsoleApiErrorEnvelope + /** + * Forbidden + */ + 403: ConsoleApiErrorEnvelope + /** + * Not found + */ + 404: ConsoleApiErrorEnvelope + /** + * Adapter registry unavailable + */ + 503: ConsoleApiErrorEnvelope +} + +export type AnkoleWebSignalBindingControllerShowError = + AnkoleWebSignalBindingControllerShowErrors[keyof AnkoleWebSignalBindingControllerShowErrors] + +export type AnkoleWebSignalBindingControllerShowResponses = { + /** + * Signal binding + */ + 200: SignalBindingDetailResponse +} + +export type AnkoleWebSignalBindingControllerShowResponse = + AnkoleWebSignalBindingControllerShowResponses[keyof AnkoleWebSignalBindingControllerShowResponses] + export type AnkoleWebSignalBindingControllerUpdateBindingData = { /** * Signal binding update diff --git a/app/webapps/console/console-form.test.tsx b/app/webapps/console/console-form.test.tsx index 54dcddbb4..fb4fc7fff 100644 --- a/app/webapps/console/console-form.test.tsx +++ b/app/webapps/console/console-form.test.tsx @@ -2,7 +2,8 @@ import { describe, expect, test } from 'bun:test' import { Input } from '@ankole/uikit' import { renderToStaticMarkup } from 'react-dom/server' import { MemoryRouter } from 'react-router' -import { LabeledField, ResourceEditorPage } from './console-form' +import { ConfigFields } from '../common/config-fields' +import { LabeledField, ReadOnlyValue, ResourceEditorPage } from './console-form' describe('console forms', () => { test('lets application validation handle required fields on editor submit', () => { @@ -32,6 +33,18 @@ describe('console forms', () => { expect(html).toContain('Name is required') }) + test('connects a disabled field to the reason it is unavailable', () => { + const html = renderToStaticMarkup( + + + + ) + + expect(html).toContain('disabled=""') + expect(html).toContain('aria-describedby=') + expect(html).toContain('Select a provider first.') + }) + test('gives configuration-heavy editors the workspace width', () => { const html = renderToStaticMarkup( @@ -43,4 +56,49 @@ describe('console forms', () => { expect(html).toContain('max-w-6xl') }) + + test('does not duplicate the header back link with a footer cancel action', () => { + const html = renderToStaticMarkup( + + {}} title="Edit resource"> + + + + ) + + expect(html.match(/href="\/resources"/g)).toHaveLength(1) + }) + + test('centers read-only values within the same control height as inputs', () => { + const html = renderToStaticMarkup(slack-main) + + expect(html).toContain('min-h-10') + expect(html).toContain('items-center') + expect(html).toContain('bg-muted') + expect(html).not.toContain('bg-transparent') + expect(html).not.toContain('bg-background') + expect(html).toContain('cursor-default') + expect(html).toContain('text-muted-foreground') + expect(html).not.toContain('text-foreground') + expect(html).toContain('leading-5') + }) + + test('keeps a stored secret out of the editable input value', () => { + const html = renderToStaticMarkup( + {}} + /> + ) + + expect(html).toContain('type="password"') + expect(html).toContain('autoComplete="new-password"') + expect(html).toContain('placeholder="Saved — enter a new value to replace"') + expect(html).toContain('value=""') + expect(html).not.toContain('required=""') + }) }) diff --git a/app/webapps/console/console-form.tsx b/app/webapps/console/console-form.tsx index a30b8039c..232b3528e 100644 --- a/app/webapps/console/console-form.tsx +++ b/app/webapps/console/console-form.tsx @@ -51,7 +51,8 @@ import { formatJSONDraft, inspectJSONDraft } from './state/json-editor' /** * Editor-page frame: back link, header, error surface, the form body, and a - * sticky footer with the primary submit plus a cancel link back to the list. + * sticky footer with the primary submit. The header back link owns navigation + * away from the editor. * Destructive and out-of-band actions (delete, sync) slot into `secondary`. */ export function ResourceEditorPage({ @@ -134,9 +135,6 @@ export function ResourceEditorPage({ type="submit"> {submitLabel ?? t('common.save')} - - {t('common.cancel')} - {disabledReason ? (

{disabledReason} @@ -322,8 +320,8 @@ export function ReadOnlyValue({ children, mono = false }: { children: ReactNode; return (

{children === null || children === undefined || children === '' ? '—' : children}
@@ -450,7 +448,7 @@ export function ConfirmDeleteButton({ }}> - event.stopPropagation()}> + event.stopPropagation()}> {confirm.title} {confirm.description ? {confirm.description} : null} diff --git a/app/webapps/console/console-list-page.test.tsx b/app/webapps/console/console-list-page.test.tsx new file mode 100644 index 000000000..0a3d8d062 --- /dev/null +++ b/app/webapps/console/console-list-page.test.tsx @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { MemoryRouter } from 'react-router' +import { ResourceListPage, RowActions, RowViewAction, SubNav } from './console-list-page' + +describe('ResourceListPage', () => { + test('keeps the sticky action cell active while its row menu is open', () => { + const html = renderToStaticMarkup( + + + + Resource one + + + + + ) + + expect(html).toContain('tbody_tr:has([aria-expanded=true])_td:last-child') + }) +}) + +describe('SubNav', () => { + test('gives sibling resource links a named navigation landmark', () => { + const html = renderToStaticMarkup( + + + + ) + + expect(html).toContain('