Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and in an `X-Export-Warning` response header — rather than silently
swapped for a different model. The create wizard's language-model
field is now a free-text input backed by a `<datalist>` of known
models, defaulting to the detected model's canonical id.
models, defaulting to the detected model's canonical id. On top of
that datalist, a Choices.js Stimulus controller
(`assets/controllers/language_model_picker_controller.js`) turns the
input into a tag-based combobox: the dropdown offers the union of
the canonical shortlist and every `languageModel` value already in
the catalogue (case-insensitive dedup, canonical spelling wins), and
aliases from `model_map.yaml` fuel a fuzzy search so typing
`openai/gpt` narrows the dropdown to `gpt-4o`. `AssistantCreator`
folds aliases and legacy spellings to their canonical id on persist,
keeping the catalogue's language-model facet deduplicated even when
curators submit variant spellings. The pill shows whatever the
curator typed. A new `AssistantRepository::persistedLanguageModels()`
feeds the union list and a new `ModelMap::aliasesFor()` exposes the
per-canonical alias tokens the picker searches on.
- Exports are validated against the target format's own rules before
download, so a broken payload is never emitted; `FormatAdapter` gains
`requiredCanonicalFields()` and the registry a `requiredForAnyExport()`
Expand Down
138 changes: 78 additions & 60 deletions assets/controllers/language_model_picker_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,82 @@ import Choices from "choices.js";
/*
* Choices.js on the wizard's language-model `<select>`.
*
* Base shape: select-one search combobox seeded from the
* `<option>` list Symfony pre-renders (SUPPORTED_LANGUAGE_MODELS
* ∪ previously-persisted values).
* The template pre-renders the option list as the union of
* ModelMap::choices() and every persisted `languageModel`
* value already in the catalogue (server-side, so no-JS
* clients see the same options). This controller wraps the
* select in Choices.js's select-one search combobox and
* adds two behaviours the raw widget doesn't offer:
*
* Free-tag on the fly: every keystroke, if the typed query
* isn't already a known option, we append it to the choice
* list as a regular option. That way Choices.js's own search
* filter surfaces the typed value as a pickable row alongside
* any matching known options — Enter or click commits it, and
* the committed option is a plain one (no "+ Add" prefix, no
* `data-custom-properties` marker). Once picked, the newly-
* committed value gets promoted to the known-options list so
* subsequent keystrokes don't re-add it.
* 1. Alias-aware filter: we own the `search` event
* (`searchChoices: false`) and re-emit a filtered choice
* list that matches value, label, or any alias declared
* in model_map.yaml. Typing `openai/gpt` narrows the
* dropdown to the canonical `gpt-4o` row.
*
* Turning JS off leaves a plain `<select>` behind.
* 2. Free-tag on the fly: if the typed value doesn't match
* any known option, we append it to the filtered list as
* a pickable row. Enter or click commits it verbatim, and
* the committed value gets promoted to the known set so
* subsequent keystrokes don't duplicate it. The pill
* shows whatever the curator typed; server-side
* normalisation is what folds aliases to canonical ids.
*
* Turning JS off leaves a plain `<select>` behind, populated
* with the same option list.
*/
export default class extends Controller {
static values = {
aliases: Object,
placeholder: String,
addItemText: String,
};

connect() {
// The controller is bound to a wrapper `<div>` (not the `<select>`
// itself) so Choices.js can re-parent the select into its own DOM
// wrapper without dragging the controller node out of the tree —
// that would fire disconnect/connect in a loop.
this.select = this.element.querySelector("select");
if (!this.select) {
return;
}
// Read the known options straight off the pre-rendered
// `<option>` list — Symfony already wrote them into the
// DOM from the SupportedLanguageModels service.
this.knownOriginals = Array.from(this.select.options)
.map((o) => String(o.value))
.filter((v) => "" !== v);
this.knownLower = new Set(
this.knownOriginals.map((v) => v.toLowerCase()),
);

this.choices = new Choices(this.select, {
// Snapshot the pre-rendered option list so the search hook
// can rebuild the choice list from a stable source of truth.
this.known = Array.from(this.select.options)
.filter((o) => "" !== o.value)
.map((o) => ({ value: String(o.value), label: o.textContent }));
this.knownLower = new Set(this.known.map((c) => c.value.toLowerCase()));

// Map canonical id → array of lower-cased alias tokens the
// filter hook consults when a typed query misses the value +
// label match.
this.aliasesLower = new Map();
const aliases = this.aliasesValue || {};
for (const id of Object.keys(aliases)) {
const list = Array.isArray(aliases[id]) ? aliases[id] : [];
this.aliasesLower.set(
id,
list.map((a) => String(a).toLowerCase()),
);
}

this.instance = new Choices(this.select, {
allowHTML: false,
searchEnabled: true,
// We filter the choice list ourselves in `onSearch` so
// we can (a) keep the filter in sync with the typed
// value round-trip below and (b) inject the free-typed
// value as a pickable option without Choices.js's own
// filter fighting our `setChoices` call. Every keystroke
// rebuilds the choice list from a pre-filtered known
// set — one source of truth, no flicker.
searchChoices: false,
searchResultLimit: 50,
shouldSort: false,
removeItemButton: false,
placeholder: true,
placeholderValue: this.placeholderValue,
searchPlaceholderValue: this.placeholderValue,
placeholderValue: this.placeholderValue || "",
searchPlaceholderValue: this.placeholderValue || "",
addItemText: (value) =>
(this.addItemTextValue || 'Add "__QUERY__"').replace(
"__QUERY__",
String(value),
),
});

this.onSearch = this.onSearch.bind(this);
Expand All @@ -70,35 +93,40 @@ export default class extends Controller {
this.select.removeEventListener("search", this.onSearch);
this.select.removeEventListener("choice", this.onChoice);
}
if (this.choices) {
this.choices.destroy();
this.choices = null;
if (this.instance) {
this.instance.destroy();
this.instance = null;
}
}

onSearch(event) {
const query = String(event.detail?.value ?? "").trim();
const queryLower = query.toLowerCase();
const q = query.toLowerCase();

// Filter the known set to case-insensitive substring
// matches on the query. Empty query → whole set.
const filtered =
"" === query
? this.knownOriginals
: this.knownOriginals.filter((v) =>
v.toLowerCase().includes(queryLower),
);
"" === q
? this.known
: this.known.filter((c) => {
if (c.value.toLowerCase().includes(q)) {
return true;
}
if (c.label.toLowerCase().includes(q)) {
return true;
}
const aliasList = this.aliasesLower.get(c.value) || [];
return aliasList.some((a) => a.includes(q));
});

const list = filtered.map((v) => ({ value: v, label: v }));
const list = filtered.map((c) => ({
value: c.value,
label: c.label,
}));

// If the typed value isn't already in the known set,
// append it as a pickable row so Enter / click commits
// it as-is.
if ("" !== query && !this.knownLower.has(queryLower)) {
if ("" !== q && !this.knownLower.has(q)) {
list.push({ value: query, label: query });
}

this.choices.setChoices(list, "value", "label", true);
this.instance.setChoices(list, "value", "label", true);
}

onChoice(event) {
Expand All @@ -110,17 +138,7 @@ export default class extends Controller {
if (this.knownLower.has(lower)) {
return;
}
// The picked value was a just-added free-tag. Promote
// it to the known-options list so future keystrokes
// don't re-add it, and rebuild the choice list to the
// canonical shape (no per-search injection lingering).
this.knownOriginals.push(value);
this.known.push({ value: String(value), label: String(value) });
this.knownLower.add(lower);
this.choices.setChoices(
this.knownOriginals.map((v) => ({ value: v, label: v })),
"value",
"label",
true,
);
}
}
11 changes: 10 additions & 1 deletion src/Assistant/AssistantCreator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Assistant;

use App\Assistant\Format\FormatAdapterRegistry;
use App\Assistant\Model\ModelMap;
use App\Entity\Assistant;
use App\Entity\Tag;
use App\Repository\TagRepository;
Expand All @@ -24,11 +25,13 @@ final class AssistantCreator
* @param FormatAdapterRegistry $formats resolves the adapter that validates and parses the upload
* @param EntityManagerInterface $entityManager Doctrine entity manager that persists the Assistant
* @param TagRepository $tags resolves tag names to shared Tag entities
* @param ModelMap $modelMap folds alias/legacy model ids to their canonical form on persist
*/
public function __construct(
private readonly FormatAdapterRegistry $formats,
private readonly EntityManagerInterface $entityManager,
private readonly TagRepository $tags,
private readonly ModelMap $modelMap,
) {
}

Expand Down Expand Up @@ -72,10 +75,16 @@ public function create(

$source = $this->formats->get($framework)->parseToSource($rawConfig);

// Fold aliases and legacy spellings to the canonical id defined
// in config/model_map.yaml, so the catalogue's language-model
// facet stays deduplicated no matter which spelling a curator
// typed. Unknown/free-typed values pass through untouched.
$canonicalModel = $this->modelMap->normalise($languageModel) ?? $languageModel;

$assistant = new Assistant(
title: $title,
description: $description,
languageModel: $languageModel,
languageModel: $canonicalModel,
framework: $framework,
tags: $this->resolveTags($tags),
);
Expand Down
28 changes: 24 additions & 4 deletions src/Assistant/Model/ModelMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ final class ModelMap
/**
* Canonical model definitions keyed by canonical id; null until loaded.
*
* @var array<string, array{label: string, targets: array<string, string|null>}>|null
* @var array<string, array{label: string, targets: array<string, string|null>, aliases: list<string>}>|null
*/
private ?array $models = null;

Expand Down Expand Up @@ -140,6 +140,26 @@ public function label(string $canonicalId): ?string
return $this->models[$canonicalId]['label'] ?? null;
}

/**
* The alias spellings recognised for a canonical id.
*
* The canonical id itself is not repeated in the returned list —
* only the aliases as declared in `config/model_map.yaml`. Feeds
* client-side search on the picker so typing an alias filters the
* dropdown to the canonical entry.
*
* @param string $canonicalId a canonical model id
*
* @return list<string> declared aliases in map order; empty when
* the id has no aliases or is unknown
*/
public function aliasesFor(string $canonicalId): array
{
$this->load();

return $this->models[$canonicalId]['aliases'] ?? [];
}

/**
* Read, validate structurally, and memoise the model map.
*
Expand Down Expand Up @@ -184,10 +204,10 @@ private function load(): void
foreach ((array) ($definition['targets'] ?? []) as $formatId => $targetId) {
$targets[(string) $formatId] = \is_string($targetId) ? $targetId : null;
}
$definitions[$id] = ['label' => $label, 'targets' => $targets];
$aliases = array_values(array_filter((array) ($definition['aliases'] ?? []), 'is_string'));
$definitions[$id] = ['label' => $label, 'targets' => $targets, 'aliases' => $aliases];

$aliases = array_filter((array) ($definition['aliases'] ?? []), 'is_string');
foreach ([$id, ...array_values($aliases)] as $spelling) {
foreach ([$id, ...$aliases] as $spelling) {
$index[$this->key($spelling)] = $id;
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/Form/AssistantJsonStepType.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
),
new ValidAssistantConfig(groups: ['json']),
],
// Bubble the config's validation errors up to the flow root
// so the wizard's shell renders them once in its alert box
// instead of rendering them there AND next to the textarea.
// The parent step has `inherit_data: true`, whose default
// `error_bubbling` is true, so the errors continue past the
// step compound and land on `flow` automatically.
'error_bubbling' => true,
'attr' => [
'class' => self::INPUT_CLASS,
'rows' => 10,
Expand Down
Loading