Uniform UX settings framework for agents and clients (#46)

* refactor: unify agent and client UX settings on shared ux.schema field framework (#43)

* review fixes: shared conditionMet helper for all condition evaluation sites, add table to FieldType
This commit is contained in:
veguAI
2026-07-02 15:21:56 +03:00
committed by GitHub
parent 21d9c1c3b4
commit 380ec0f663
13 changed files with 599 additions and 229 deletions

View File

@@ -1,5 +1,6 @@
0.39.0.dev:
improvements:
- "Uniform Settings Framework: Agent settings and client settings now share one field-definition schema on the backend and one field renderer on the frontend. Client-specific settings gain the full widget set previously exclusive to agents (sliders, autocompletes, selects with rich choices, per-value notes) plus conditional visibility, and choice lists are delivered to the frontend in a single normalized shape."
- "MistralAI Client: Added a Concurrent Inference toggle so batch operations can dispatch multiple requests in parallel. Off by default."
0.38.0.dev:

View File

@@ -24,7 +24,8 @@ from talemate.agents.context import ActiveAgent, active_agent
from talemate.emit import emit
from talemate.events import GameLoopStartEvent
from talemate.context import active_scene
from talemate.ux.schema import Action, Column, Note
from talemate.ux.schema import Action, Condition, Note
from talemate.ux.schema import Field as UxField
from talemate.config import get_config, Config
import talemate.config.schema as config_schema
from talemate.client.context import (
@@ -53,53 +54,21 @@ __all__ = [
log = structlog.get_logger("talemate.agents.base")
class AgentActionConditional(pydantic.BaseModel):
attribute: str
value: int | float | str | bool | list[int | float | str | bool] | None = None
# Backwards-compat aliases — the shared UX schema now owns these shapes.
AgentActionConditional = Condition
AgentActionNote = Note
class AgentActionNote(Note):
pass
class AgentActionConfig(UxField):
"""
Agent setting field — extends the uniform UX field definition
(talemate.ux.schema.Field) with agent-specific behavior.
"""
class AgentActionConfig(pydantic.BaseModel):
type: Literal[
"autocomplete",
"blob",
"bool",
"flags",
"number",
"text",
"vector2",
"weights",
"wstemplate",
"password",
"unified_api_key",
]
label: str
description: str = ""
value: int | float | str | bool | list | dict | None = None
default_value: int | float | str | bool | None = None
max: int | float | None = None
min: int | float | None = None
step: int | float | None = None
graduations: list[dict[str, int | float]] | None = None
scope: str = "global"
choices: (
list[dict[str, str | int | float | bool | list[int | float | bool]]] | None
) = None
note: AgentActionNote | None = None
expensive: bool = False
scope: Literal["global", "scene"] = "global"
quick_toggle: bool = False
condition: AgentActionConditional | None = None
title: str | None = None
value_migration: Callable | None = pydantic.Field(default=None, exclude=True)
columns: list[Column] | None = None
note_on_value: dict[str | int | float | bool, AgentActionNote] = pydantic.Field(
default_factory=dict
)
save_on_change: bool = False
scene_overridable: bool = True
wstemplate_type: (
@@ -117,25 +86,6 @@ class AgentActionConfig(pydantic.BaseModel):
) = None
wstemplate_filter: dict[str, str] | None = None
@pydantic.field_validator("note", mode="before")
@classmethod
def validate_note(cls, v):
if isinstance(v, str):
return AgentActionNote(text=v)
return v
@pydantic.model_validator(mode="after")
def ensure_note_is_object(self):
if isinstance(self.note, str):
self.note = AgentActionNote(text=self.note)
return self
@pydantic.field_serializer("note")
def serialize_note(self, v):
if isinstance(v, str):
return AgentActionNote(text=v)
return v
model_config = ConfigDict(arbitrary_types_allowed=True)

View File

@@ -116,7 +116,7 @@ class AnthropicClient(ConcurrentInferenceMixin, EndpointOverrideMixin, ClientBas
extra_fields: dict[str, ExtraField] = {
"thinking_mode": ExtraField(
name="thinking_mode",
type="select",
type="text",
label="Thinking Mode",
choices=["budget", "adaptive"],
description="'budget' uses fixed token budget (legacy), 'adaptive' lets the model decide when to think. Adaptive is recommended for Opus 4.6+ and required for Opus 4.7+ (budget mode is ignored on those models).",
@@ -130,7 +130,7 @@ class AnthropicClient(ConcurrentInferenceMixin, EndpointOverrideMixin, ClientBas
),
"effort_level": ExtraField(
name="effort_level",
type="select",
type="text",
label="Effort Level",
choices=["low", "medium", "high", "xhigh", "max"],
description="Controls thinking depth and cost trade-off. Higher effort = better quality but more cost/latency. Only applies with adaptive thinking mode. The 'xhigh' option (between high and max) is supported on Opus 4.7+.",

View File

@@ -154,22 +154,18 @@ class Defaults(CommonDefaults, pydantic.BaseModel):
lock_template: bool = False
class FieldGroup(pydantic.BaseModel):
name: str
label: str
description: str
icon: str = "mdi-cog"
# Backwards-compat alias — the shared UX schema now owns this shape.
FieldGroup = ux_schema.FieldGroup
class ExtraField(pydantic.BaseModel):
name: str
type: str
label: str
required: bool
description: str
group: FieldGroup | None = None
note: ux_schema.Note | None = None
choices: list[str | int | float | bool] | None = None
class ExtraField(ux_schema.Field):
"""
Client setting field — the uniform UX field definition
(talemate.ux.schema.Field), rendered by the frontend through the same
shared component as agent settings.
"""
pass
class ReasoningDisplay(pydantic.BaseModel):

View File

@@ -1,11 +1,34 @@
from typing import Literal
import pydantic
__all__ = [
"Action",
"Note",
"Condition",
"FieldGroup",
"FieldType",
"Field",
"Column",
]
# Widget types understood by the shared frontend field renderer
# (talemate_frontend/src/components/UxField.vue).
FieldType = Literal[
"autocomplete",
"blob",
"bool",
"flags",
"number",
"table",
"text",
"vector2",
"weights",
"wstemplate",
"password",
"unified_api_key",
]
class Action(pydantic.BaseModel):
action_name: str
@@ -23,21 +46,123 @@ class Note(pydantic.BaseModel):
actions: list[Action] = pydantic.Field(default_factory=list)
class Field(pydantic.BaseModel):
class Condition(pydantic.BaseModel):
"""
Conditional visibility for a field (or a container of fields): the
frontend only renders the item when the referenced attribute holds the
given value (or one of the given values when `value` is a list).
How `attribute` is resolved depends on the context the field is rendered
in — agent settings resolve it against the agent's action config values,
client settings resolve it against the client's field values.
"""
attribute: str
value: int | float | str | bool | list[int | float | str | bool] | None = None
class FieldGroup(pydantic.BaseModel):
"""
Groups related fields together — the frontend renders one section/tab per
group.
"""
name: str
label: str
type: str
value: int | float | str | bool | list | None = None
choices: list[dict[str, str | int | float | bool]] = pydantic.Field(
default_factory=list
)
max: int | float | None = None
min: int | float | None = None
step: int | float | None = None
description: str = ""
icon: str = "mdi-cog"
class Field(pydantic.BaseModel):
"""
Uniform UX field definition.
This is the shared schema for user-configurable settings rendered by the
frontend — agent action configs (talemate.agents.base.AgentActionConfig)
and client extra fields (talemate.client.base.ExtraField) both extend it.
The frontend renders any of these through the shared UxField component.
"""
# Field identifier. Optional because some containers (e.g. agent action
# config dicts) key their fields externally.
name: str = ""
type: FieldType
label: str
description: str = ""
value: int | float | str | bool | list | dict | None = None
default_value: int | float | str | bool | None = None
# number widgets
min: int | float | None = None
max: int | float | None = None
step: int | float | None = None
graduations: list[dict[str, int | float]] | None = None
# choice widgets — always a list of {"label": ..., "value": ...} dicts;
# scalar shorthand entries are normalized by the validator below.
choices: (
list[dict[str, str | int | float | bool | list[int | float | bool]]] | None
) = None
# table widgets
columns: list["Column"] | None = None
note: Note | None = None
note_on_value: dict[str | int | float | bool, Note] = pydantic.Field(
default_factory=dict
)
condition: Condition | None = None
group: FieldGroup | None = None
required: bool = False
# marks settings that can cause many additional prompts when enabled
expensive: bool = False
# value changes should be saved immediately rather than on dialog save
save_on_change: bool = False
@pydantic.field_validator("choices", mode="before")
@classmethod
def normalize_choices(cls, v):
if v is None:
return v
return [
choice
if isinstance(choice, dict)
else {"label": str(choice), "value": choice}
for choice in v
]
@pydantic.field_validator("note", mode="before")
@classmethod
def coerce_note(cls, v):
if isinstance(v, str):
return Note(text=v)
return v
@pydantic.field_validator("note_on_value", mode="before")
@classmethod
def coerce_note_on_value(cls, v):
if isinstance(v, dict):
return {
key: Note(text=note) if isinstance(note, str) else note
for key, note in v.items()
}
return v
# notes can also be assigned as plain strings after construction
# (assignment bypasses validation), so coerce again at dump time
@pydantic.field_serializer("note")
def serialize_note(self, v):
if isinstance(v, str):
return Note(text=v)
return v
class Column(Field):
pass
# resolve the "Column" forward reference in Field.columns
Field.model_rebuild()

View File

@@ -46,8 +46,8 @@
<v-divider class="mb-2"></v-divider>
</div>
<AgentSettingField
:action-config="action_config"
<UxField
:field="action_config"
:model-value="action.config[config_key].value"
:templates="templates"
:app-config="appConfig"
@@ -63,14 +63,15 @@
<script>
import { getProperty } from 'dot-prop';
import AgentSettingField from './AgentSettingField.vue';
import { conditionMet } from '@/utils/uxConditions';
import UxField from './UxField.vue';
// Renders one AgentAction in Global mode. Scene mode lives in
// [[AgentSceneSettings.vue]]. The per-field widget rendering is owned by
// [[AgentSettingField.vue]] and shared with the scene-mode renderer.
// [[UxField.vue]] and shared with the scene-mode renderer.
export default {
components: {
AgentSettingField,
UxField,
},
props: {
// Live mutable agent (deep-cloned in AgentModal). We mutate action via
@@ -106,18 +107,13 @@ export default {
// action is rendered.
if (typeof this.agent.client !== 'object') return true;
const value = getProperty(this.agent.actions, action.condition.attribute + ".value");
if (Array.isArray(action.condition.value)) {
return action.condition.value.some(v => v == value);
}
return value == action.condition.value;
return conditionMet(action.condition, value);
},
testConfigConditional(config) {
if (config.condition == null) return true;
const value = getProperty(this.agent.actions, config.condition.attribute + ".value");
if (Array.isArray(config.condition.value)) {
return config.condition.value.some(v => v == value);
}
return value == config.condition.value;
const value = config.condition
? getProperty(this.agent.actions, config.condition.attribute + ".value")
: null;
return conditionMet(config.condition, value);
},
},
};

View File

@@ -145,6 +145,7 @@
<script>
import {getProperty} from 'dot-prop';
import { conditionMet } from '@/utils/uxConditions';
import AgentGlobalSettings from './AgentGlobalSettings.vue';
import AgentSceneSettings from './AgentSceneSettings.vue';
import DynamicAgentRegistry from './DynamicAgentRegistry.vue';
@@ -411,11 +412,8 @@ export default {
testActionConditional(action) {
if (action.condition == null) return true;
if (typeof(this.agent.client) !== 'object') return true;
let value = getProperty(this.agent.actions, action.condition.attribute + ".value");
if (Array.isArray(action.condition.value)) {
return action.condition.value.some(v => v == value);
}
return value == action.condition.value;
const value = getProperty(this.agent.actions, action.condition.attribute + ".value");
return conditionMet(action.condition, value);
},
close() {

View File

@@ -34,8 +34,8 @@
<!-- Per-field overrides -->
<div v-for="(action_config, config_key) in actionSchema.config" :key="config_key">
<div v-if="action_config.scene_overridable">
<AgentSettingField
:action-config="action_config"
<UxField
:field="action_config"
:model-value="effectiveValue(config_key)"
:readonly="!isOverrideActive(config_key)"
:templates="templates"
@@ -48,7 +48,7 @@
@toggle="toggleFieldOverride(config_key)"
/>
</template>
</AgentSettingField>
</UxField>
</div>
</div>
</v-sheet>
@@ -69,11 +69,11 @@
<script>
import { actionHasOverridable } from '@/constants/sceneAgentSettings';
import AgentSettingField from './AgentSettingField.vue';
import UxField from './UxField.vue';
import SceneOverrideToggle from './SceneOverrideToggle.vue';
export default {
components: { AgentSettingField, SceneOverrideToggle },
components: { UxField, SceneOverrideToggle },
props: {
// Live mutable global action (deep clone owned by the modal). Read for
// global values; NOT mutated by this component.
@@ -84,9 +84,9 @@ export default {
// Per-action sparse override slice: {enabled?: bool, config: {key: {value}}}
// Always an object; empty when nothing is overridden.
overrides: { type: Object, default: () => ({}) },
// Forwarded to AgentSettingField — required for wstemplate widgets.
// Forwarded to UxField — required for wstemplate widgets.
templates: { type: Object, default: null },
// Forwarded to AgentSettingField — required for unified_api_key widgets.
// Forwarded to UxField — required for unified_api_key widgets.
appConfig: { type: Object, default: null },
},
emits: ['update:overrides', 'change'],
@@ -175,9 +175,9 @@ export default {
</script>
<style scoped>
/* Same pointer-events fix as inside AgentSettingField — needed here for the
/* Same pointer-events fix as inside UxField — needed here for the
container-level enabled override checkbox, which is rendered directly in
this template (not via AgentSettingField). */
this template (not via UxField). */
:deep(.v-input--disabled) .v-input__prepend,
:deep(.v-input--readonly) .v-input__prepend {
pointer-events: auto;

View File

@@ -77,10 +77,7 @@
<template v-if="!simpleView">
<v-row v-for="field in generalExtraFields" :key="field.name">
<v-col cols="12">
<v-text-field v-model="client[field.name]" v-if="field.type === 'text'" :label="field.label"
:rules="[rules.required]" :hint="field.description"></v-text-field>
<v-checkbox v-else-if="field.type === 'bool'" v-model="client[field.name]"
:label="field.label" :hint="field.description" density="compact"></v-checkbox>
<UxField :field="field" v-model="client[field.name]" :app-config="appConfig" />
</v-col>
</v-row>
<v-row>
@@ -258,10 +255,7 @@
<!-- Extra fields promoted to reasoning tab -->
<v-row v-for="field in extraFieldsByTab['reasoning']" :key="field.name">
<v-col cols="12">
<v-text-field v-if="field.type === 'text'" v-model="client[field.name]" :label="field.label" :hint="field.description" persistent-hint></v-text-field>
<v-checkbox v-else-if="field.type === 'bool'" v-model="client[field.name]" :label="field.label" :hint="field.description" persistent-hint></v-checkbox>
<v-select v-else-if="field.type === 'select'" v-model="client[field.name]" :label="field.label" :hint="field.description" :items="field.choices" persistent-hint></v-select>
<v-alert v-if="field.note" :color="field.note.color" variant="text" density="compact" :icon="field.note.icon" class="mt-2 pre-wrap text-caption">{{ field.note.text.replace(/{client_type}/g, client.type) }}</v-alert>
<UxField :field="field" v-model="client[field.name]" :app-config="appConfig" />
</v-col>
</v-row>
</v-window-item>
@@ -342,13 +336,7 @@
<v-alert v-if="group.description" color="muted" variant="text" density="compact" :icon="group.icon" class="mb-2 pre-wrap">{{ group.description.replace(/{client_type}/g, client.type) }}</v-alert>
<v-row v-for="field in extraFieldsByGroup[group.name]" :key="field.name">
<v-col cols="12">
<!-- handle `text`, `bool`, `password` -->
<v-text-field v-if="field.type === 'text'" v-model="client[field.name]" :label="field.label" :hint="field.description"></v-text-field>
<v-checkbox v-else-if="field.type === 'bool'" v-model="client[field.name]" :label="field.label" :hint="field.description"></v-checkbox>
<v-text-field v-else-if="field.type === 'password'" v-model="client[field.name]" :label="field.label" :hint="field.description" type="password"></v-text-field>
<v-select v-else-if="field.type === 'flags'" v-model="client[field.name]" :label="field.label" :hint="field.description" :items="field.choices" multiple chips
></v-select>
<v-alert v-if="field.note" :color="field.note.color" variant="text" density="compact" :icon="field.note.icon" class="mt-2 pre-wrap text-caption">{{ field.note.text.replace(/{client_type}/g, client.type) }}</v-alert>
<UxField :field="field" v-model="client[field.name]" :app-config="appConfig" />
</v-col>
</v-row>
</v-window-item>
@@ -370,9 +358,11 @@
<script>
import { conditionMet } from '@/utils/uxConditions';
import AppConfigPresetsSystemPrompts from './AppConfigPresetsSystemPrompts.vue';
import ConfigWidgetUnifiedApiKey from './ConfigWidgetUnifiedApiKey.vue';
import GraduatedSlider from './GraduatedSlider.vue';
import UxField from './UxField.vue';
export default {
props: {
@@ -386,6 +376,7 @@ export default {
AppConfigPresetsSystemPrompts,
ConfigWidgetUnifiedApiKey,
GraduatedSlider,
UxField,
},
inject: [
'state',
@@ -484,26 +475,27 @@ export default {
// List of hardcoded tab names that extra fields can be promoted to
return Object.keys(this.tabs);
},
visibleExtraFields() {
// extra fields whose visibility condition is met against the current
// client values
return Object.values(this.clientMeta().extra_fields || {}).filter(this.extraFieldConditionMet);
},
generalExtraFields() {
// returns extra fields that have a null group and are to be shown in the general tab
if (!this.clientMeta().extra_fields) {
return [];
}
return Object.values(this.clientMeta().extra_fields).filter(field => !field.group);
return this.visibleExtraFields
.filter(field => !field.group)
.map(this.prepareExtraField);
},
extraFieldsByTab() {
// returns an object with the tab name as the key and the fields as the value
// this allows extra fields to be promoted to hardcoded tabs when group.name matches
const fieldsByTab = {};
if (!this.clientMeta().extra_fields) {
return {};
}
Object.values(this.clientMeta().extra_fields).forEach(field => {
this.visibleExtraFields.forEach(field => {
if (field.group && this.hardcodedTabs.includes(field.group.name)) {
if (!fieldsByTab[field.group.name]) {
fieldsByTab[field.group.name] = [];
}
fieldsByTab[field.group.name].push(field);
fieldsByTab[field.group.name].push(this.prepareExtraField(field));
}
});
return fieldsByTab;
@@ -512,10 +504,7 @@ export default {
// returns an array of group objects from the extra fields, carefully only entering each group
// once based on the group name, excluding groups that match hardcoded tab names
const groups = {};
if (!this.clientMeta().extra_fields) {
return [];
}
Object.values(this.clientMeta().extra_fields).forEach(field => {
this.visibleExtraFields.forEach(field => {
if (field.group && !this.hardcodedTabs.includes(field.group.name)) {
groups[field.group.name] = field.group;
}
@@ -526,15 +515,12 @@ export default {
// returns an object with the group name as the key and the fields as the value
// excludes fields that belong to hardcoded tabs (those are rendered via extraFieldsByTab)
const fieldsByGroup = {};
if (!this.clientMeta().extra_fields) {
return {};
}
Object.values(this.clientMeta().extra_fields).forEach(field => {
this.visibleExtraFields.forEach(field => {
if (field.group && !this.hardcodedTabs.includes(field.group.name)) {
if (!fieldsByGroup[field.group.name]) {
fieldsByGroup[field.group.name] = [];
}
fieldsByGroup[field.group.name].push(field);
fieldsByGroup[field.group.name].push(this.prepareExtraField(field));
}
});
return fieldsByGroup;
@@ -600,6 +586,27 @@ export default {
}
},
methods: {
prepareExtraField(field) {
// Clone the field definition and substitute {client_type} in the
// user-facing texts before handing it to the shared UxField renderer.
const prepared = JSON.parse(JSON.stringify(field));
const sub = (text) => text ? text.replace(/{client_type}/g, this.client.type) : text;
prepared.description = sub(prepared.description);
if (prepared.note) {
prepared.note.text = sub(prepared.note.text);
}
for (const key in prepared.note_on_value) {
prepared.note_on_value[key].text = sub(prepared.note_on_value[key].text);
}
return prepared;
},
extraFieldConditionMet(field) {
// Conditions on client extra fields resolve against the client's
// current field values (mirrors how agent settings resolve conditions
// against action config values).
const value = field.condition ? this.client[field.condition.attribute] : null;
return conditionMet(field.condition, value);
},
setSystemPrompts(systemPrompts) {
this.client.system_prompts = systemPrompts;
},

View File

@@ -17,7 +17,7 @@
<script>
// Tiny icon-button used by [[AgentSceneSettings.vue]] to toggle whether a
// given field overrides the global value. Lives in the `prepend` slot of
// the shared [[AgentSettingField.vue]] so the field renders identically to
// the shared [[UxField.vue]] so the field renders identically to
// the global view.
export default {
props: {

View File

@@ -1,14 +1,15 @@
<template>
<!-- text -->
<v-text-field
v-if="actionConfig.type === 'text' && actionConfig.choices === null"
v-if="field.type === 'text' && !hasChoices"
:model-value="modelValue"
@update:modelValue="onValue"
@keyup="$emit('change')"
@blur="$emit('change', actionConfig.save_on_change)"
@blur="$emit('change', field.save_on_change)"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:label="field.label"
:hint="field.description"
:rules="requiredRules"
density="compact"
class="mt-3"
>
@@ -17,15 +18,16 @@
<!-- password -->
<v-text-field
v-else-if="actionConfig.type === 'password'"
v-else-if="field.type === 'password'"
type="password"
:model-value="modelValue"
@update:modelValue="onValue"
@keyup="$emit('change')"
@blur="$emit('change', actionConfig.save_on_change)"
@blur="$emit('change', field.save_on_change)"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:label="field.label"
:hint="field.description"
:rules="requiredRules"
density="compact"
class="mt-3"
>
@@ -34,13 +36,14 @@
<!-- blob -->
<v-textarea
v-else-if="actionConfig.type === 'blob'"
v-else-if="field.type === 'blob'"
:model-value="modelValue"
@update:modelValue="onValue"
@keyup="$emit('change')"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:label="field.label"
:hint="field.description"
:rules="requiredRules"
density="compact"
rows="5"
class="mt-3"
@@ -50,13 +53,14 @@
<!-- autocomplete -->
<v-autocomplete
v-else-if="actionConfig.type === 'autocomplete' && actionConfig.choices !== null"
v-else-if="field.type === 'autocomplete' && hasChoices"
:model-value="modelValue"
@update:modelValue="onValueAndChange"
:items="actionConfig.choices"
:items="field.choices"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:label="field.label"
:hint="field.description"
:rules="requiredRules"
item-title="label"
item-value="value"
density="compact"
@@ -67,13 +71,13 @@
<!-- wstemplate (world-state template selector) -->
<v-autocomplete
v-else-if="actionConfig.type === 'wstemplate'"
v-else-if="field.type === 'wstemplate'"
:model-value="modelValue"
@update:modelValue="onValueAndCommit"
:items="wstemplateChoices"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:label="field.label"
:hint="field.description"
item-title="label"
item-value="value"
density="compact"
@@ -87,13 +91,14 @@
<!-- select (text + choices) -->
<v-select
v-else-if="actionConfig.type === 'text' && actionConfig.choices !== null"
v-else-if="field.type === 'text' && hasChoices"
:model-value="modelValue"
@update:modelValue="onValueAndCommit"
:items="actionConfig.choices"
:items="field.choices"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:label="field.label"
:hint="field.description"
:rules="requiredRules"
item-title="label"
item-value="value"
:menu-props="{ maxHeight: 480 }"
@@ -105,13 +110,13 @@
<!-- flags (multi-select chips) -->
<v-select
v-else-if="actionConfig.type === 'flags'"
v-else-if="field.type === 'flags'"
:model-value="modelValue"
@update:modelValue="onValueAndChange"
:items="actionConfig.choices"
:items="field.choices"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:label="field.label"
:hint="field.description"
item-title="label"
item-subtitle="help"
item-value="value"
@@ -127,21 +132,21 @@
we wrap it externally when a prepend is provided. See the v-slider
branch below for the rationale on align-start. -->
<div
v-else-if="actionConfig.type === 'number' && actionConfig.graduations"
v-else-if="field.type === 'number' && field.graduations"
:class="$slots.prepend ? 'd-flex align-start mt-3' : 'mt-3'"
>
<div v-if="$slots.prepend" class="agent-setting-field__outer-prepend">
<div v-if="$slots.prepend" class="ux-field__outer-prepend">
<slot name="prepend" />
</div>
<GraduatedSlider
:model-value="modelValue"
@update:modelValue="onValueAndChange"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:min="actionConfig.min"
:max="actionConfig.max"
:graduations="actionConfig.graduations"
:label="field.label"
:hint="field.description"
:min="field.min"
:max="field.max"
:graduations="field.graduations"
density="compact"
color="primary"
thumb-label="always"
@@ -155,21 +160,21 @@
the toggle with the slider's label row (which sits at the top of the
slider) rather than with the vertical center of the slider track. -->
<div
v-else-if="actionConfig.type === 'number'"
v-else-if="field.type === 'number'"
:class="$slots.prepend ? 'd-flex align-start mt-3' : 'mt-3'"
>
<div v-if="$slots.prepend" class="agent-setting-field__outer-prepend">
<div v-if="$slots.prepend" class="ux-field__outer-prepend">
<slot name="prepend" />
</div>
<v-slider
:model-value="modelValue"
@update:modelValue="onValueAndChange"
:readonly="readonly"
:label="actionConfig.label"
:hint="actionConfig.description"
:min="actionConfig.min"
:max="actionConfig.max"
:step="actionConfig.step || 1"
:label="field.label"
:hint="field.description"
:min="field.min"
:max="field.max"
:step="field.step || 1"
density="compact"
color="primary"
thumb-label="always"
@@ -179,12 +184,12 @@
<!-- boolean -->
<v-checkbox
v-else-if="actionConfig.type === 'bool'"
v-else-if="field.type === 'bool'"
:model-value="modelValue"
@update:modelValue="onValueAndChange"
:disabled="readonly"
:label="actionConfig.label"
:messages="actionConfig.description"
:label="field.label"
:messages="field.description"
density="compact"
color="primary"
class="mt-3"
@@ -192,7 +197,7 @@
<template v-if="$slots.prepend" v-slot:prepend><slot name="prepend" /></template>
<template v-slot:message="{ message }">
<span class="text-caption text-grey">{{ message }}</span>
<span v-if="actionConfig.expensive" class="text-warning mt-2 text-caption">
<span v-if="field.expensive" class="text-warning mt-2 text-caption">
<v-icon size="x-small">mdi-alert-circle-outline</v-icon>
Potential for many additional prompts.
</span>
@@ -200,12 +205,12 @@
</v-checkbox>
<!-- vector2 (numeric pair, optional choice presets) -->
<v-row v-else-if="actionConfig.type === 'vector2'" class="mt-3">
<v-row v-else-if="field.type === 'vector2'" class="mt-3">
<v-col cols="12" class="d-flex align-center">
<slot v-if="$slots.prepend" name="prepend" />
<div class="text-caption text-muted text-uppercase">{{ actionConfig.label }}</div>
<div class="text-caption text-muted text-uppercase">{{ field.label }}</div>
</v-col>
<v-col :cols="actionConfig.choices ? 5 : 6">
<v-col :cols="field.choices ? 5 : 6">
<v-number-input
:model-value="modelValue?.[0]"
@update:modelValue="(v) => onVector2Update(0, v)"
@@ -215,7 +220,7 @@
density="compact"
></v-number-input>
</v-col>
<v-col :cols="actionConfig.choices ? 5 : 6">
<v-col :cols="field.choices ? 5 : 6">
<v-number-input
:model-value="modelValue?.[1]"
@update:modelValue="(v) => onVector2Update(1, v)"
@@ -225,7 +230,7 @@
density="compact"
></v-number-input>
</v-col>
<v-col cols="2" v-if="actionConfig.choices" class="d-flex align-center justify-center">
<v-col cols="2" v-if="field.choices" class="d-flex align-center justify-center">
<v-menu location="bottom end" :disabled="readonly">
<template v-slot:activator="{ props: activatorProps }">
<v-chip v-bind="activatorProps" size="small" variant="tonal" color="primary" class="px-2">
@@ -234,7 +239,7 @@
</template>
<v-list density="compact">
<v-list-item
v-for="(choice, i) in actionConfig.choices"
v-for="(choice, i) in field.choices"
:key="i"
:value="i"
@click="onValueAndChange([...choice.value])"
@@ -247,60 +252,60 @@
</v-row>
<!-- table custom widget; emits the full values array on save. -->
<div v-else-if="actionConfig.type === 'table'" :class="$slots.prepend ? 'd-flex align-start' : ''">
<div v-if="$slots.prepend" class="agent-setting-field__outer-prepend mt-3">
<div v-else-if="field.type === 'table'" :class="$slots.prepend ? 'd-flex align-start' : ''">
<div v-if="$slots.prepend" class="ux-field__outer-prepend mt-3">
<slot name="prepend" />
</div>
<ConfigWidgetTable
class="flex-grow-1"
:columns="actionConfig.columns"
:columns="field.columns"
:default_values="modelValue"
:label="actionConfig.label"
:description="actionConfig.description"
:label="field.label"
:description="field.description"
@save="onValueAndChange"
/>
</div>
<!-- weights custom widget. -->
<div v-else-if="actionConfig.type === 'weights'" :class="$slots.prepend ? 'd-flex align-start' : ''">
<div v-if="$slots.prepend" class="agent-setting-field__outer-prepend mt-3">
<div v-else-if="field.type === 'weights'" :class="$slots.prepend ? 'd-flex align-start' : ''">
<div v-if="$slots.prepend" class="ux-field__outer-prepend mt-3">
<slot name="prepend" />
</div>
<ConfigWidgetWeights
class="flex-grow-1"
:model-value="modelValue"
@update:modelValue="onValueAndChange"
:choices="actionConfig.choices"
:label="actionConfig.label"
:description="actionConfig.description"
:step="actionConfig.step || 0.05"
:choices="field.choices"
:label="field.label"
:description="field.description"
:step="field.step || 0.05"
/>
</div>
<!-- unified_api_key bound to global app config; no per-field value. -->
<ConfigWidgetUnifiedApiKey
v-else-if="actionConfig.type === 'unified_api_key'"
:config-path="actionConfig.value"
:title="actionConfig.label"
v-else-if="field.type === 'unified_api_key'"
:config-path="field.value"
:title="field.label"
:app-config="appConfig"
class="mt-3"
/>
<!-- fallback -->
<v-alert v-else density="compact" variant="text" color="muted" class="mt-3">
<span class="text-caption">Widget type "{{ actionConfig.type }}" ({{ actionConfig.label }}) is not supported.</span>
<span class="text-caption">Widget type "{{ field.type }}" ({{ field.label }}) is not supported.</span>
</v-alert>
<!-- Field notes `note_on_value` matches against the current modelValue,
which in scene mode is the effective value (override when active). -->
<template v-if="actionConfig.note != null">
<v-alert variant="outlined" density="compact" :color="actionConfig.note.color || 'muted'" :icon="actionConfig.note.icon">
<div class="text-caption text-mutedheader">{{ actionConfig.note.title || actionConfig.label }}</div>
<span class="text-muted text-caption">{{ actionConfig.note.text }}</span>
<template v-if="field.note != null">
<v-alert variant="outlined" density="compact" :color="field.note.color || 'muted'" :icon="field.note.icon">
<div class="text-caption text-mutedheader">{{ field.note.title || field.label }}</div>
<span class="text-muted text-caption">{{ field.note.text }}</span>
</v-alert>
</template>
<template v-else-if="actionConfig.note_on_value != null">
<template v-for="(note, noteKey) in actionConfig.note_on_value" :key="noteKey">
<template v-else-if="field.note_on_value != null">
<template v-for="(note, noteKey) in field.note_on_value" :key="noteKey">
<v-alert v-if="modelValue == noteKey || String(modelValue) == noteKey" variant="outlined" density="compact" :color="note.color || 'muted'" class="my-2" :icon="note.icon">
<span class="text-caption text-uppercase mr-2">
{{ noteKey.toLowerCase() === 'true' ? 'ENABLED' : noteKey.replace(/_/g, ' ') }}
@@ -318,14 +323,16 @@ import ConfigWidgetUnifiedApiKey from './ConfigWidgetUnifiedApiKey.vue';
import ConfigWidgetWeights from './ConfigWidgetWeights.vue';
import GraduatedSlider from './GraduatedSlider.vue';
// Renders a single AgentActionConfig field across all supported widget
// types. Shared by [[AgentGlobalSettings.vue]] and [[AgentSceneSettings.vue]];
// the latter passes a `prepend` slot for the override toggle and `readonly`
// when the override is inactive.
// Renders a single uniform UX field definition (talemate.ux.schema.Field)
// across all supported widget types. Shared by the agent settings renderers
// ([[AgentGlobalSettings.vue]] and [[AgentSceneSettings.vue]] the latter
// passes a `prepend` slot for the override toggle and `readonly` when the
// override is inactive) and the client extra-field renderer
// ([[ClientModal.vue]]).
export default {
components: { ConfigWidgetTable, ConfigWidgetUnifiedApiKey, ConfigWidgetWeights, GraduatedSlider },
props: {
actionConfig: { type: Object, required: true },
field: { type: Object, required: true },
modelValue: { type: null, default: null },
readonly: { type: Boolean, default: false },
// Required for wstemplate widgets.
@@ -335,15 +342,24 @@ export default {
},
emits: ['update:modelValue', 'change'],
computed: {
hasChoices() {
return this.field.choices != null;
},
requiredRules() {
if (!this.field.required) return [];
return [
v => !(v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)) || `${this.field.label} is required`,
];
},
wstemplateChoices() {
const bucket = this.templates?.by_type?.[this.actionConfig?.wstemplate_type];
const bucket = this.templates?.by_type?.[this.field?.wstemplate_type];
if (!bucket) return [];
const groupNameByUid = Object.fromEntries(
(this.templates?.managed?.groups ?? [])
.filter(Boolean)
.map(g => [g.uid, g.name || g.uid])
);
const filter = this.actionConfig?.wstemplate_filter;
const filter = this.field?.wstemplate_filter;
const hasFilter = filter && typeof filter === 'object' && Object.keys(filter).length > 0;
const items = [];
for (const [uid, template] of Object.entries(bucket)) {
@@ -376,7 +392,7 @@ export default {
},
onValueAndCommit(value) {
this.$emit('update:modelValue', value);
this.$emit('change', this.actionConfig.save_on_change);
this.$emit('change', this.field.save_on_change);
},
onVector2Update(index, value) {
const next = [...(this.modelValue || [0, 0])];
@@ -403,7 +419,7 @@ export default {
the toggle aligns horizontally with prepend toggles on neighbouring
widgets. The small padding-top nudges the toggle icon down to align
with the host widget's label baseline (used with align-start parent). */
.agent-setting-field__outer-prepend {
.ux-field__outer-prepend {
display: flex;
align-items: center;
padding-inline-end: 8px;

View File

@@ -0,0 +1,14 @@
// Evaluates the shared UX field/action visibility condition
// (talemate.ux.schema.Condition) against an already-resolved value.
//
// Callers own the value resolution — agent settings resolve the condition
// attribute against action config values, client settings against the
// client's field values. Loose equality is intentional: values cross the
// websocket boundary and may arrive as string/number/bool variants.
export function conditionMet(condition, resolvedValue) {
if (condition == null) return true;
if (Array.isArray(condition.value)) {
return condition.value.some(v => v == resolvedValue);
}
return resolvedValue == condition.value;
}

267
tests/test_ux_schema.py Normal file
View File

@@ -0,0 +1,267 @@
"""
Unit tests for the uniform UX field framework (`talemate.ux.schema`) and its
integration into agent action configs (`talemate.agents.base.AgentActionConfig`)
and client extra fields (`talemate.client.base.ExtraField`).
"""
from __future__ import annotations
import pydantic
import pytest
import talemate.ux.schema as ux_schema
from talemate.agents.base import (
AgentAction,
AgentActionConditional,
AgentActionConfig,
AgentActionNote,
)
from talemate.client.base import ExtraField, FieldGroup
# ---------------------------------------------------------------------------
# Field base
# ---------------------------------------------------------------------------
def test_field_choices_scalar_normalization():
field = ux_schema.Field(type="text", label="Mode", choices=["budget", "adaptive"])
assert field.choices == [
{"label": "budget", "value": "budget"},
{"label": "adaptive", "value": "adaptive"},
]
def test_field_choices_dict_passthrough():
choices = [{"label": "A", "value": "a"}, {"label": "B", "value": "b", "help": "x"}]
field = ux_schema.Field(type="text", label="Mode", choices=choices)
assert field.choices == choices
def test_field_choices_mixed_normalization():
field = ux_schema.Field(
type="flags",
label="Flags",
choices=["plain", {"label": "Rich", "value": "rich"}],
)
assert field.choices == [
{"label": "plain", "value": "plain"},
{"label": "Rich", "value": "rich"},
]
def test_field_choices_none_stays_none():
field = ux_schema.Field(type="text", label="Name")
assert field.choices is None
def test_field_note_string_coercion():
field = ux_schema.Field(type="text", label="Name", note="a helpful note")
assert isinstance(field.note, ux_schema.Note)
assert field.note.text == "a helpful note"
def test_field_note_string_assignment_serializes_as_note():
# post-construction assignment bypasses validation; the serializer must
# still emit the Note object shape
field = ux_schema.Field(type="text", label="Name")
field.note = "assigned later"
dumped = field.model_dump()
assert dumped["note"] == {
"text": "assigned later",
"title": None,
"color": None,
"icon": None,
"actions": [],
}
def test_field_rejects_unknown_type():
with pytest.raises(pydantic.ValidationError):
ux_schema.Field(type="select", label="Legacy")
def test_field_table_type_with_columns():
field = ux_schema.Field(
type="table",
label="Rows",
columns=[
ux_schema.Column(name="key", type="text", label="Key"),
ux_schema.Column(name="weight", type="number", label="Weight", min=0),
],
)
dumped = field.model_dump()
assert [c["name"] for c in dumped["columns"]] == ["key", "weight"]
def test_field_condition_serialization():
field = ux_schema.Field(
type="text",
label="Name",
condition=ux_schema.Condition(attribute="other_field", value=["a", "b"]),
)
dumped = field.model_dump()
assert dumped["condition"] == {"attribute": "other_field", "value": ["a", "b"]}
def test_field_group_serialization():
field = ux_schema.Field(
type="text",
label="Name",
group=ux_schema.FieldGroup(name="grp", label="Group", description="desc"),
)
dumped = field.model_dump()
assert dumped["group"]["name"] == "grp"
assert dumped["group"]["icon"] == "mdi-cog"
# ---------------------------------------------------------------------------
# AgentActionConfig on the shared base
# ---------------------------------------------------------------------------
def test_agent_action_config_is_uniform_field():
assert issubclass(AgentActionConfig, ux_schema.Field)
def test_agent_action_conditional_is_shared_condition():
assert AgentActionConditional is ux_schema.Condition
assert AgentActionNote is ux_schema.Note
def test_agent_action_config_defaults_preserved():
config = AgentActionConfig(type="number", label="Length", value=5, min=1, max=10)
assert config.scope == "global"
assert config.scene_overridable is True
assert config.quick_toggle is False
assert config.save_on_change is False
assert config.choices is None
assert config.value_migration is None
def test_agent_action_config_note_coercion_and_note_on_value():
config = AgentActionConfig(
type="bool",
label="Enabled",
value=True,
note="plain string note",
note_on_value={True: AgentActionNote(text="on"), False: "off"},
)
assert config.note.text == "plain string note"
dumped = config.model_dump()
assert dumped["note"]["text"] == "plain string note"
assert dumped["note_on_value"][True]["text"] == "on"
assert dumped["note_on_value"][False]["text"] == "off"
def test_agent_action_config_value_migration_excluded_from_dump():
config = AgentActionConfig(type="text", label="Name", value_migration=lambda v: v)
assert "value_migration" not in config.model_dump()
def test_agent_action_serializes_config_via_shared_schema():
action = AgentAction(
label="Test action",
config={
"mode": AgentActionConfig(
type="text",
label="Mode",
value="a",
choices=[{"label": "A", "value": "a"}],
condition=AgentActionConditional(
attribute="_config.config.x", value=True
),
)
},
)
dumped = action.model_dump()
config = dumped["config"]["mode"]
assert config["type"] == "text"
assert config["choices"] == [{"label": "A", "value": "a"}]
assert config["condition"] == {"attribute": "_config.config.x", "value": True}
# uniform-schema keys are present for the frontend renderer
for key in ("required", "group", "note_on_value", "save_on_change"):
assert key in config
# ---------------------------------------------------------------------------
# ExtraField on the shared base
# ---------------------------------------------------------------------------
def test_extra_field_is_uniform_field():
assert issubclass(ExtraField, ux_schema.Field)
assert FieldGroup is ux_schema.FieldGroup
def test_extra_field_minimal_construction():
field = ExtraField(name="my_field", type="bool", label="My Field")
assert field.required is False
assert field.group is None
assert field.description == ""
def test_extra_field_scalar_choices_normalized():
field = ExtraField(
name="mode",
type="text",
label="Mode",
choices=["budget", "adaptive"],
)
assert field.choices == [
{"label": "budget", "value": "budget"},
{"label": "adaptive", "value": "adaptive"},
]
def test_extra_field_gains_uniform_capabilities():
# capabilities that used to be agent-only are now available to client
# extra fields through the shared schema
field = ExtraField(
name="quality",
type="number",
label="Quality",
min=1,
max=10,
step=1,
condition=ux_schema.Condition(attribute="enabled", value=True),
note_on_value={10: "maximum quality is expensive"},
)
dumped = field.model_dump()
assert dumped["min"] == 1
assert dumped["condition"]["attribute"] == "enabled"
assert dumped["note_on_value"][10]["text"] == "maximum quality is expensive"
# ---------------------------------------------------------------------------
# Client Meta serialization (websocket payload shape)
# ---------------------------------------------------------------------------
def test_client_meta_extra_fields_serialize_uniformly():
import talemate.client # noqa: F401 - ensure client registration
from talemate.client.registry import CLIENT_CLASSES
for client_type, cls in CLIENT_CLASSES.items():
meta = cls.Meta().model_dump()
for name, field in (meta.get("extra_fields") or {}).items():
# legacy "select" type must not appear anywhere
assert field["type"] != "select", (client_type, name)
# choices, when present, are normalized {label, value} dicts
for choice in field["choices"] or []:
assert isinstance(choice, dict), (client_type, name)
assert "label" in choice and "value" in choice, (client_type, name)
# uniform keys the shared renderer relies on
for key in ("name", "type", "label", "description", "required"):
assert key in field, (client_type, name)
def test_anthropic_thinking_mode_select_migrated_to_choices():
import talemate.client # noqa: F401
from talemate.client.registry import CLIENT_CLASSES
meta = CLIENT_CLASSES["anthropic"].Meta().model_dump()
thinking_mode = meta["extra_fields"]["thinking_mode"]
assert thinking_mode["type"] == "text"
assert {"label": "budget", "value": "budget"} in thinking_mode["choices"]
assert {"label": "adaptive", "value": "adaptive"} in thinking_mode["choices"]