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 @@