diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml
index c69932e8..0e5272b9 100644
--- a/CHANGELOG.yaml
+++ b/CHANGELOG.yaml
@@ -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:
diff --git a/src/talemate/agents/base.py b/src/talemate/agents/base.py
index c0a1f7da..4ddda0f4 100644
--- a/src/talemate/agents/base.py
+++ b/src/talemate/agents/base.py
@@ -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)
diff --git a/src/talemate/client/anthropic.py b/src/talemate/client/anthropic.py
index dea65dfd..27338f1b 100644
--- a/src/talemate/client/anthropic.py
+++ b/src/talemate/client/anthropic.py
@@ -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+.",
diff --git a/src/talemate/client/base.py b/src/talemate/client/base.py
index 602a8a28..a95bce79 100644
--- a/src/talemate/client/base.py
+++ b/src/talemate/client/base.py
@@ -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):
diff --git a/src/talemate/ux/schema.py b/src/talemate/ux/schema.py
index 4f430f71..3733cedd 100644
--- a/src/talemate/ux/schema.py
+++ b/src/talemate/ux/schema.py
@@ -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()
diff --git a/talemate_frontend/src/components/AgentGlobalSettings.vue b/talemate_frontend/src/components/AgentGlobalSettings.vue
index 6ddddff1..67ba32a4 100644
--- a/talemate_frontend/src/components/AgentGlobalSettings.vue
+++ b/talemate_frontend/src/components/AgentGlobalSettings.vue
@@ -46,8 +46,8 @@
-
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);
},
},
};
diff --git a/talemate_frontend/src/components/AgentModal.vue b/talemate_frontend/src/components/AgentModal.vue
index aef3ff01..b8e9fdd8 100644
--- a/talemate_frontend/src/components/AgentModal.vue
+++ b/talemate_frontend/src/components/AgentModal.vue
@@ -145,6 +145,7 @@