diff --git a/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py b/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py new file mode 100644 index 0000000000..73d3687c1f --- /dev/null +++ b/backend/open_webui/migrations/versions/b0018471bbbe_add_user_variables.py @@ -0,0 +1,31 @@ +"""add user variables + +Revision ID: b0018471bbbe +Revises: c49178636c78 +Create Date: 2026-07-24 01:21:46.457057 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'b0018471bbbe' +down_revision: Union[str, None] = 'c49178636c78' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [col['name'] for col in inspector.get_columns('user')] + + if 'variables' not in columns: + op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('user', 'variables') diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index d555198b22..1eff4932d5 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -9,7 +9,7 @@ from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.utils.misc import throttle from open_webui.utils.validate import validate_profile_image_url -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy import ( JSON, BigInteger, @@ -69,6 +69,7 @@ class User(Base): # identity & profile # Metadata info = Column(JSON, nullable=True) + variables = Column(JSON, nullable=True) settings = Column(JSON, nullable=True) oauth = Column(JSON, nullable=True) scim = Column(JSON, nullable=True) @@ -105,6 +106,7 @@ class UserModel(BaseModel): status_expires_at: int | None = None info: dict | None = None + variables: dict = Field(default_factory=dict, exclude=True) settings: UserSettings | None = None oauth: dict | None = None @@ -126,6 +128,11 @@ class UserModel(BaseModel): self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id) return self + @field_validator('variables', mode='before') + @classmethod + def normalize_variables(cls, value): + return value if isinstance(value, dict) else {} + class UserStatusModel(UserModel): is_active: bool = False diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index f0cbb6dd16..54559c748e 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -44,6 +44,7 @@ from open_webui.utils.auth import ( get_verified_user, validate_password, ) +from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -574,6 +575,52 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy return user.info +class UserVariablesForm(BaseModel): + variables: dict = Field(default_factory=dict) + + +class UserVariablesResponse(BaseModel): + variables: dict[str, str] = Field(default_factory=dict) + + +############################ +# GetUserVariablesBySessionUser +############################ + + +@router.get('/user/variables', response_model=UserVariablesResponse) +async def get_user_variables_by_session_user(user=Depends(get_verified_user)): + return UserVariablesResponse(variables=normalize_user_variables(user.variables)) + + +############################ +# UpdateUserVariablesBySessionUser +############################ + + +@router.post('/user/variables/update', response_model=UserVariablesResponse) +async def update_user_variables_by_session_user( + form_data: UserVariablesForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + try: + variables = validate_user_variables(form_data.variables) + except ChatVariablesError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db) + if not updated: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + return UserVariablesResponse(variables=variables) + + ############################ # UpdateUserInfoBySessionUser ############################ diff --git a/backend/open_webui/utils/chat_variables.py b/backend/open_webui/utils/chat_variables.py index f639ed845a..37438254aa 100644 --- a/backend/open_webui/utils/chat_variables.py +++ b/backend/open_webui/utils/chat_variables.py @@ -7,6 +7,7 @@ from typing import Any CHAT_VARIABLE_KEY_RE = re.compile(r'^[a-z][a-z0-9_]*$') CHAT_VARIABLE_ANY_RE = re.compile(r'{{\s*chat\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}') +USER_VARIABLE_ANY_RE = re.compile(r'{{\s*user\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}') MAX_VARIABLE_VALUE_LENGTH = 20_000 MAX_VARIABLES_JSON_LENGTH = 100_000 @@ -173,6 +174,36 @@ def normalize_chat_variables(variables: Any) -> dict[str, Any]: return variables +def normalize_user_variables(variables: Any) -> dict[str, str]: + if not isinstance(variables, dict): + return {} + return {key: value for key, value in variables.items() if isinstance(key, str) and isinstance(value, str)} + + +def validate_user_variables(variables: Any) -> dict[str, str]: + if not isinstance(variables, dict): + raise ChatVariablesError('User variables must be an object.') + + try: + if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH: + raise ChatVariablesError('User variables are too large.') + except TypeError: + raise ChatVariablesError('User variables must be JSON serializable.') + + validated: dict[str, str] = {} + for key, value in variables.items(): + if not isinstance(key, str) or not CHAT_VARIABLE_KEY_RE.match(key): + raise ChatVariablesError(f'Invalid user variable key: {key}') + if not isinstance(value, str): + raise ChatVariablesError(f'User variable must be a string: {key}') + value = value.replace('\r\n', '\n') + if len(value) > MAX_VARIABLE_VALUE_LENGTH: + raise ChatVariablesError(f'User variable is too long: {key}') + validated[key] = value + + return validated + + def validate_chat_variables( system_prompt: str | None, variables: Any, @@ -240,3 +271,18 @@ def render_chat_variables( return '' if value is None else str(value) return CHAT_VARIABLE_ANY_RE.sub(replace, system_prompt) + + +def render_user_variables(system_prompt: str | None, variables: Any) -> str | None: + if not system_prompt: + return system_prompt + + variables = normalize_user_variables(variables) + + def replace(match: re.Match) -> str: + key = match.group(1).strip() + if not CHAT_VARIABLE_KEY_RE.match(key): + return '' + return variables.get(key, '') + + return USER_VARIABLE_ANY_RE.sub(replace, system_prompt) diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index d0618a31cd..89568c3a63 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -7,7 +7,7 @@ from open_webui.utils.misc import ( deep_update, replace_system_message_content, ) -from open_webui.utils.chat_variables import render_chat_variables +from open_webui.utils.chat_variables import render_chat_variables, render_user_variables from open_webui.utils.task import prompt_template, prompt_variables_template @@ -26,6 +26,8 @@ async def resolve_system_prompt( required=False, ) + system = render_user_variables(system, getattr(user, 'variables', {}) if user else {}) + # Metadata (WebUI Usage) if metadata: variables = metadata.get('variables', {}) diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index 95c0ebac18..82556e7822 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -440,6 +440,62 @@ export const updateUserInfo = async (token: string, info: object) => { return res; }; +export const getUserVariables = async (token: string) => { + let error = null; + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateUserVariables = async (token: string, variables: Record) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + variables + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getAndUpdateUserLocation = async (token: string) => { const location = await getUserPosition().catch((err) => { console.error(err); diff --git a/src/lib/components/chat/Settings/Account.svelte b/src/lib/components/chat/Settings/Account.svelte index 66cd65a6a6..1800aad8e4 100644 --- a/src/lib/components/chat/Settings/Account.svelte +++ b/src/lib/components/chat/Settings/Account.svelte @@ -4,6 +4,7 @@ import { user, config } from '$lib/stores'; import { updateUserProfile, createAPIKey, getAPIKey, getSessionUser } from '$lib/apis/auths'; + import { getUserVariables, updateUserVariables } from '$lib/apis/users'; import { WEBUI_BASE_URL } from '$lib/constants'; import UpdatePassword from './Account/UpdatePassword.svelte'; @@ -14,6 +15,7 @@ import Tooltip from '$lib/components/common/Tooltip.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Textarea from '$lib/components/common/Textarea.svelte'; + import Modal from '$lib/components/common/Modal.svelte'; import UserProfileImage from './Account/UserProfileImage.svelte'; import UserSettingField from './UserSettingField.svelte'; import UserSettingRow from './UserSettingRow.svelte'; @@ -38,15 +40,99 @@ let APIKey = ''; let APIKeyCopied = false; + let variableRows: { key: string; value: string }[] = []; + let variableModalOpen = false; + let variableFormIndex: number | null = null; + let variableFormKey = ''; + let variableFormValue = ''; const textareaClass = 'w-full resize-y rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; const inputClass = 'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; + const variableValueClass = + 'w-full resize-none rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500'; const actionButtonClass = 'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white'; + const variableRowClass = (idx: number) => + `flex min-h-7 w-full items-center gap-2 py-0.5 ${ + idx > 0 ? 'border-t border-gray-50 dark:border-white/[0.04]' : '' + }`; + const variableKeyRegex = /^[a-z][a-z0-9_]*$/; + + const setVariableRows = (variables = {}) => { + variableRows = Object.entries(variables).map(([key, value]) => ({ + key, + value: String(value ?? '') + })); + }; + + const openVariableModal = (idx: number | null = null) => { + const row = idx === null ? null : variableRows[idx]; + variableFormIndex = idx; + variableFormKey = row?.key ?? ''; + variableFormValue = row?.value ?? ''; + variableModalOpen = true; + }; + + const removeVariable = (idx: number) => { + variableRows = variableRows.filter((_, rowIdx) => rowIdx !== idx); + }; + + const saveVariableForm = () => { + const key = variableFormKey.trim(); + + if (!variableKeyRegex.test(key)) { + toast.error($i18n.t('Variable keys must use lowercase snake case.')); + return; + } + if (variableRows.some((row, idx) => idx !== variableFormIndex && row.key === key)) { + toast.error($i18n.t('Variable keys must be unique.')); + return; + } + + const row = { key, value: variableFormValue ?? '' }; + variableRows = + variableFormIndex === null + ? [...variableRows, row] + : variableRows.map((current, idx) => (idx === variableFormIndex ? row : current)); + variableModalOpen = false; + }; + + const deleteVariableForm = () => { + if (variableFormIndex !== null) { + removeVariable(variableFormIndex); + } + variableModalOpen = false; + }; + + const getVariablesPayload = () => { + const variables: Record = {}; + for (const row of variableRows) { + const key = row.key.trim(); + if (!key && !row.value) { + continue; + } + if (!variableKeyRegex.test(key)) { + throw $i18n.t('Variable keys must use lowercase snake case.'); + } + if (Object.prototype.hasOwnProperty.call(variables, key)) { + throw $i18n.t('Variable keys must be unique.'); + } + variables[key] = row.value ?? ''; + } + return variables; + }; const submitHandler = async () => { + let variables: Record; + try { + variables = getVariablesPayload(); + } catch (error) { + toast.error(`${error}`); + return false; + } + if (name !== $user?.name) { if (profileImageUrl === generateInitialsImage($user?.name) || profileImageUrl === '') { profileImageUrl = generateInitialsImage(name); @@ -63,7 +149,13 @@ toast.error(`${error}`); }); - if (updatedUser) { + const variablesRes = await updateUserVariables(localStorage.token, variables).catch((error) => { + toast.error(`${error}`); + return null; + }); + + if (updatedUser && variablesRes) { + setVariableRows(variablesRes.variables ?? {}); // Get Session User Info const sessionUser = await getSessionUser(localStorage.token).catch((error) => { toast.error(`${error}`); @@ -102,6 +194,12 @@ dateOfBirth = user?.date_of_birth ?? ''; } + const userVariables = await getUserVariables(localStorage.token).catch((error) => { + toast.error(`${error}`); + return null; + }); + setVariableRows(userVariables?.variables ?? {}); + // Only fetch API key if the feature is enabled and user has permission if ( user && @@ -206,6 +304,52 @@ + + {variableRows.length} + +
+
+
+ {$i18n.t('Use these in model system prompts as {{example}}.', { + example: '{{user.variables.key_name}}' + })} +
+ +
+ +
+
+ +
+ {#each variableRows as row, idx} +
+
+ {row.key || $i18n.t('key_name')} +
+
+ {row.value || $i18n.t('Empty')} +
+ +
+ {/each} + + {#if variableRows.length === 0} +
+ {$i18n.t('No user variables configured.')} +
+ {/if} +
+
+
+ {#if $config?.features.enable_login_form && $config?.features.enable_password_change_form} @@ -397,3 +541,68 @@ + + +
+

+ {variableFormIndex === null ? $i18n.t('Add User Variable') : $i18n.t('Edit User Variable')} +

+ +
+ {$i18n.t('Key')} +
+ + +
+ {$i18n.t('Value')} +
+