mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-09-01 19:48:52 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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+.",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
14
talemate_frontend/src/utils/uxConditions.js
Normal file
14
talemate_frontend/src/utils/uxConditions.js
Normal 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
267
tests/test_ux_schema.py
Normal 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"]
|
||||
Reference in New Issue
Block a user