diff --git a/plugins/_model_config/AGENTS.md b/plugins/_model_config/AGENTS.md index aab70195a..156566522 100644 --- a/plugins/_model_config/AGENTS.md +++ b/plugins/_model_config/AGENTS.md @@ -36,6 +36,7 @@ - Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`. - The `Agent.read_prompt/end` Anthropic hook adapts only the two bundled thoughts instructions in main/communication/solving prompt results when the effective main provider or model name contains `anthropic` (case-insensitive, including OpenRouter). Keep prompt files, JSON fields, history, custom wording, and other models unchanged; standalone communication reads must match main-prompt rendering for native protocol projection. Verify with `tests/test_anthropic_thoughts_prompt.py`. - `modelConfig.createPresetEditor()` owns local preset drafts, row actions, and stable UI-only row keys so deletion or renaming cannot rebind nested model fields. +- Presets may change outside the editor, so the `open_modal_before` hook reloads them before every preset-editor open, including `/presets`, and resolves the chat's active preset unless `openPresetEditor()` pinned one. - The preset editor maps each model provider's API-key field to the shared API-key store; saving the editor persists dirty keys separately and never writes secrets into preset YAML. - The compact chat selector label combines the effective preset with only the leaf name of its main model; utility and provider text stay out of the closed selector. - The compact selector strip exposes `model-context-strip-end` after the agent diff --git a/plugins/_model_config/extensions/webui/open_modal_before/refresh-preset-editor.js b/plugins/_model_config/extensions/webui/open_modal_before/refresh-preset-editor.js new file mode 100644 index 000000000..1979c5b2f --- /dev/null +++ b/plugins/_model_config/extensions/webui/open_modal_before/refresh-preset-editor.js @@ -0,0 +1,15 @@ +import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js"; +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; + +const PRESET_EDITOR = "/plugins/_model_config/webui/main.html"; + +// The editor builds its draft as soon as it renders, and presets may have changed +// outside it (another tab, the API, a plugin). Reload them before every open, +// including /presets, which opens the modal without openPresetEditor(). +export default async function refreshPresetEditor(ctx) { + if (ctx?.modalPath !== PRESET_EDITOR) return; + await Promise.all([ + modelConfigStore.loadGlobalPresets(), + modelConfigStore.preparePresetEditor(chatsStore?.selected || ""), + ]); +} diff --git a/tests/test_model_preset_editor_reload.py b/tests/test_model_preset_editor_reload.py new file mode 100644 index 000000000..fffd51929 --- /dev/null +++ b/tests/test_model_preset_editor_reload.py @@ -0,0 +1,67 @@ +import base64 +from pathlib import Path +import re +import shutil +import subprocess + +import pytest + +ROOT = Path(__file__).parents[1] + + +def module_url(path: str, prelude: str) -> str: + source = re.sub(r"^import .*?;\n", "", (ROOT / path).read_text(), flags=re.MULTILINE) + return "data:text/javascript;base64," + base64.b64encode((prelude + source).encode()).decode() + + +@pytest.mark.skipif(not shutil.which("node"), reason="node is required") +def test_preset_editor_opens_on_presets_changed_outside_it(): + store_url = module_url( + "plugins/_model_config/webui/model-config-store.js", + "const createStore = (_name, value) => value;\n" + "const fetchApi = (...args) => globalThis.fetchApi(...args);\n" + "const apiKeysState = {}, apiKeysMethods = {}, switcherState = {}, switcherMethods = {};\n", + ) + hook_url = module_url( + "plugins/_model_config/extensions/webui/open_modal_before/refresh-preset-editor.js", + "const modelConfigStore = globalThis.modelConfigStore, chatsStore = globalThis.chatsStore;\n", + ) + script = r''' +import assert from 'node:assert/strict'; +const preset = name => ({name, chat: {provider: 'p', name: 'm'}, utility: {provider: 'p', name: 'u'}, + embedding: {provider: 'p', name: 'e'}}); +let presets = [preset('Default')], selected = 'Default'; +globalThis.fetchApi = async (url, options) => { + const body = JSON.parse(options.body); + const data = url.endsWith('/model_presets') && body.action === 'get' ? {presets: structuredClone(presets)} + : url.endsWith('/model_config_get') ? {selected_preset: selected} : assert.fail(url); + return {ok: true, json: async () => data}; +}; +const { store } = await import(STORE_URL); +Object.assign(globalThis, {window: globalThis, modelConfigStore: store, chatsStore: {selected: 'chat'}}); +const { default: refreshPresetEditor } = await import(HOOK_URL); +let editor; +// modals.js runs open_modal_before first. main.html then builds the draft at once +// when presets were loaded before, otherwise after its own x-init load. +globalThis.openModal = async modalPath => { + await refreshPresetEditor({modalPath, modal: null, cancel: false}); + if (!store._presetsLoaded) await store.loadGlobalPresets(); + editor = store.createPresetEditor(store.presetEditorInitialName); +}; +const shown = () => [editor.presets.map(p => p.name).join(), editor.selectedPreset?.name]; + +await store.openPresetEditor('Default'); +assert.deepEqual(shown(), ['Default', 'Default']); + +// Another client adds a preset: the editor lists it and opens on it. +presets = [...presets, preset('probe')]; +await store.openPresetEditor('probe'); +assert.deepEqual(shown(), ['Default,probe', 'probe']); + +// /presets opens the modal directly: the editor opens on the chat's active preset. +presets = [...presets, preset('probe2')]; +selected = 'probe2'; +await globalThis.openModal('/plugins/_model_config/webui/main.html'); +assert.deepEqual(shown(), ['Default,probe,probe2', 'probe2']); +'''.replace("STORE_URL", repr(store_url)).replace("HOOK_URL", repr(hook_url)) + subprocess.run(["node", "--input-type=module", "-e", script], check=True)