refac
This commit is contained in:
@@ -220,6 +220,9 @@ from open_webui.utils.chat import (
|
||||
from open_webui.utils.chat import (
|
||||
generate_chat_completion as chat_completion_handler,
|
||||
)
|
||||
from open_webui.utils.chat_variables import (
|
||||
normalize_chat_variables,
|
||||
)
|
||||
from open_webui.utils.embeddings import generate_embeddings
|
||||
from open_webui.utils.logger import start_logger
|
||||
from open_webui.utils.middleware import (
|
||||
@@ -1135,6 +1138,13 @@ async def chat_completion(
|
||||
message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}]
|
||||
|
||||
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
|
||||
chat_id = form_data.get('chat_id') or ''
|
||||
chat_variables = form_data.pop('chat_variables', None)
|
||||
if chat_variables is None:
|
||||
existing_chat = await Chats.get_chat_by_id(chat_id) if chat_id else None
|
||||
chat_variables = existing_chat.variables if existing_chat else {}
|
||||
|
||||
chat_variables = normalize_chat_variables(chat_variables)
|
||||
|
||||
# Drop tool_servers if caller lacks features.direct_tool_servers —
|
||||
# mirrors the storage-side strip in user/settings/update.
|
||||
@@ -1166,6 +1176,7 @@ async def chat_completion(
|
||||
'files': form_data.get('files', None),
|
||||
'features': form_data.get('features', {}),
|
||||
'variables': form_data.get('variables', {}),
|
||||
'chat_variables': chat_variables,
|
||||
'model': model,
|
||||
'direct': model_item.get('direct', False),
|
||||
'params': {
|
||||
@@ -1280,6 +1291,7 @@ async def chat_completion(
|
||||
'tags': [],
|
||||
'timestamp': int(time.time() * 1000),
|
||||
},
|
||||
variables=chat_variables,
|
||||
folder_id=metadata.get('folder_id'),
|
||||
),
|
||||
)
|
||||
@@ -1385,6 +1397,8 @@ async def chat_completion(
|
||||
updated['models'] = selected_chat_models
|
||||
await Chats.update_chat_by_id(chat_id, updated)
|
||||
|
||||
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
|
||||
|
||||
# Save user message to DB
|
||||
if user_message and user_message.get('id'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
|
||||
@@ -40,6 +40,7 @@ from open_webui.models.models import (
|
||||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -48,6 +49,14 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def add_chat_variables_schema(model_dict: dict) -> dict:
|
||||
system = ((model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None)
|
||||
schema = get_chat_variables_schema(system)
|
||||
if schema:
|
||||
model_dict.setdefault('meta', {})['chat_variables_schema'] = schema
|
||||
return model_dict
|
||||
|
||||
|
||||
def _safe_static_redirect_path(url: str) -> str | None:
|
||||
"""
|
||||
If url is a same-origin static asset path, return a normalized path safe for
|
||||
@@ -177,7 +186,7 @@ async def get_models(
|
||||
# Strip profile_image_url from meta — images are served via /model/profile/image.
|
||||
items = []
|
||||
for model in result.items:
|
||||
data = model.model_dump()
|
||||
data = add_chat_variables_schema(model.model_dump())
|
||||
if data.get('meta'):
|
||||
data['meta'].pop('profile_image_url', None)
|
||||
items.append(
|
||||
@@ -527,6 +536,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
|
||||
db=db,
|
||||
):
|
||||
model_dict = model.model_dump()
|
||||
model_dict = add_chat_variables_schema(model_dict)
|
||||
# Strip params (system prompt and other admin-curated config)
|
||||
# for read-only callers — matches the params strip already
|
||||
# enforced on /api/models in utils/models.py. Owners, admins
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
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*}}')
|
||||
MAX_VARIABLE_VALUE_LENGTH = 20_000
|
||||
MAX_VARIABLES_JSON_LENGTH = 100_000
|
||||
|
||||
|
||||
class ChatVariablesError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def split_properties(value: str, delimiter: str) -> list[str]:
|
||||
result: list[str] = []
|
||||
current = ''
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for char in value:
|
||||
if escape_next:
|
||||
current += char
|
||||
escape_next = False
|
||||
continue
|
||||
|
||||
if char == '\\':
|
||||
current += char
|
||||
escape_next = True
|
||||
continue
|
||||
|
||||
if char == '"' and not escape_next:
|
||||
in_string = not in_string
|
||||
current += char
|
||||
continue
|
||||
|
||||
if not in_string:
|
||||
if char in ('{', '['):
|
||||
depth += 1
|
||||
elif char in ('}', ']'):
|
||||
depth -= 1
|
||||
|
||||
if char == delimiter and depth == 0:
|
||||
result.append(current.strip())
|
||||
current = ''
|
||||
continue
|
||||
|
||||
current += char
|
||||
|
||||
if current.strip():
|
||||
result.append(current.strip())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_json_value(value: str) -> Any:
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
return value[1:-1]
|
||||
|
||||
if re.match(r'^[\[{]', value):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def parse_variable_definition(definition: str) -> dict[str, Any]:
|
||||
parts = split_properties(definition, ':')
|
||||
if not parts:
|
||||
return {'type': 'text'}
|
||||
|
||||
first_part, *property_parts = parts
|
||||
field_type = first_part[5:] if first_part.startswith('type=') else first_part
|
||||
field_type = field_type.strip() or 'text'
|
||||
properties: dict[str, Any] = {}
|
||||
|
||||
for part in property_parts:
|
||||
trimmed = part.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
|
||||
equals_parts = split_properties(trimmed, '=')
|
||||
if len(equals_parts) == 1:
|
||||
properties[equals_parts[0].strip()] = True
|
||||
continue
|
||||
|
||||
property_name, *value_parts = equals_parts
|
||||
properties[property_name.strip()] = parse_json_value('='.join(value_parts).strip())
|
||||
|
||||
return {'type': field_type, **properties}
|
||||
|
||||
|
||||
def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed_keys = {
|
||||
'default',
|
||||
'label',
|
||||
'max',
|
||||
'maxlength',
|
||||
'min',
|
||||
'minlength',
|
||||
'options',
|
||||
'placeholder',
|
||||
'required',
|
||||
'step',
|
||||
'type',
|
||||
}
|
||||
field = {'key': key}
|
||||
for field_key in allowed_keys:
|
||||
if field_key in definition:
|
||||
field[field_key] = definition[field_key]
|
||||
|
||||
field.setdefault('type', 'text')
|
||||
if field.get('type') == 'select' and not isinstance(field.get('options'), list):
|
||||
field['options'] = []
|
||||
field['required'] = bool(field.get('required', False))
|
||||
|
||||
return field
|
||||
|
||||
|
||||
def get_chat_variables_schema(system_prompt: str | None) -> dict[str, list[dict[str, Any]]] | None:
|
||||
if not system_prompt:
|
||||
return None
|
||||
|
||||
try:
|
||||
fields_by_key = collect_chat_variable_fields(system_prompt)
|
||||
except ChatVariablesError:
|
||||
fields_by_key = {}
|
||||
|
||||
if not fields_by_key:
|
||||
return None
|
||||
|
||||
return {'fields': list(fields_by_key.values())}
|
||||
|
||||
|
||||
def collect_chat_variable_fields(system_prompt: str | None) -> dict[str, dict[str, Any]]:
|
||||
fields_by_key: dict[str, dict[str, Any]] = {}
|
||||
if not system_prompt:
|
||||
return fields_by_key
|
||||
|
||||
typed_fields_by_key: dict[str, dict[str, Any]] = {}
|
||||
for match in CHAT_VARIABLE_ANY_RE.finditer(system_prompt):
|
||||
key = match.group(1).strip()
|
||||
definition = match.group(2)
|
||||
if not CHAT_VARIABLE_KEY_RE.match(key):
|
||||
raise ChatVariablesError(f'Invalid chat variable key: {key}')
|
||||
|
||||
if definition is None or not definition.strip():
|
||||
fields_by_key.setdefault(key, _safe_field(key, {'type': 'text'}))
|
||||
continue
|
||||
|
||||
field = _safe_field(key, parse_variable_definition(definition.strip()))
|
||||
|
||||
if field.get('type') == 'select' and not field.get('options'):
|
||||
raise ChatVariablesError(f'Chat variable {key} select needs options.')
|
||||
previous = typed_fields_by_key.get(key)
|
||||
if previous and previous != field:
|
||||
raise ChatVariablesError(f'Chat variable {key} has conflicting definitions.')
|
||||
typed_fields_by_key[key] = field
|
||||
fields_by_key[key] = field
|
||||
|
||||
return fields_by_key
|
||||
|
||||
|
||||
def normalize_chat_variables(variables: Any) -> dict[str, Any]:
|
||||
if not isinstance(variables, dict):
|
||||
return {}
|
||||
return variables
|
||||
|
||||
|
||||
def validate_chat_variables(
|
||||
system_prompt: str | None,
|
||||
variables: Any,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
field_map = collect_chat_variable_fields(system_prompt)
|
||||
variables = normalize_chat_variables(variables)
|
||||
|
||||
try:
|
||||
if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
raise ChatVariablesError('Chat variables are too large.')
|
||||
except TypeError:
|
||||
raise ChatVariablesError('Chat variables must be JSON serializable.')
|
||||
|
||||
validated: dict[str, Any] = {}
|
||||
for key, field in field_map.items():
|
||||
has_value = key in variables and variables[key] not in (None, '')
|
||||
value = variables.get(key)
|
||||
|
||||
if not has_value:
|
||||
if field.get('default') not in (None, ''):
|
||||
value = field.get('default')
|
||||
has_value = True
|
||||
elif required and field.get('required'):
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Missing required chat variable: {label}')
|
||||
else:
|
||||
value = ''
|
||||
|
||||
if field.get('type') == 'select':
|
||||
options = field.get('options') or []
|
||||
if has_value and value not in options:
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Invalid value for chat variable: {label}')
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.replace('\r\n', '\n')
|
||||
if len(value) > MAX_VARIABLE_VALUE_LENGTH:
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Chat variable is too long: {label}')
|
||||
|
||||
validated[key] = value
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def render_chat_variables(
|
||||
system_prompt: str | None,
|
||||
variables: Any,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> str | None:
|
||||
if not system_prompt:
|
||||
return system_prompt
|
||||
|
||||
try:
|
||||
validated = validate_chat_variables(system_prompt, variables, required=required)
|
||||
except ChatVariablesError:
|
||||
validated = {}
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
key = match.group(1).strip()
|
||||
value = validated.get(key, '')
|
||||
return '' if value is None else str(value)
|
||||
|
||||
return CHAT_VARIABLE_ANY_RE.sub(replace, system_prompt)
|
||||
@@ -16,6 +16,7 @@ from open_webui.models.config import Config
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.routers import ollama, openai
|
||||
from open_webui.socket.utils import RedisDict
|
||||
@@ -162,6 +163,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
if custom_model.is_active:
|
||||
model['name'] = custom_model.name
|
||||
model['info'] = custom_model.model_dump()
|
||||
schema = get_chat_variables_schema(custom_model.params.model_dump().get('system'))
|
||||
if schema:
|
||||
model['info'].setdefault('meta', {})['chat_variables_schema'] = schema
|
||||
|
||||
action_ids = []
|
||||
filter_ids = []
|
||||
@@ -211,6 +215,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
}
|
||||
|
||||
info = custom_model.model_dump()
|
||||
schema = get_chat_variables_schema(custom_model.params.model_dump().get('system'))
|
||||
if schema:
|
||||
info.setdefault('meta', {})['chat_variables_schema'] = schema
|
||||
if 'params' in info:
|
||||
# Remove params to avoid exposing sensitive info
|
||||
del info['params']
|
||||
|
||||
@@ -7,6 +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.task import prompt_template, prompt_variables_template
|
||||
|
||||
|
||||
@@ -18,6 +19,13 @@ async def resolve_system_prompt(
|
||||
if not system:
|
||||
return ''
|
||||
|
||||
if metadata:
|
||||
system = render_chat_variables(
|
||||
system,
|
||||
metadata.get('chat_variables', {}),
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Metadata (WebUI Usage)
|
||||
if metadata:
|
||||
variables = metadata.get('variables', {})
|
||||
|
||||
@@ -58,7 +58,12 @@ export const updateChatConfig = async (token: string, config: object) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const createNewChat = async (token: string, chat: object, folderId: string | null) => {
|
||||
export const createNewChat = async (
|
||||
token: string,
|
||||
chat: object,
|
||||
folderId: string | null,
|
||||
variables: object | null = null
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
|
||||
@@ -70,6 +75,7 @@ export const createNewChat = async (token: string, chat: object, folderId: strin
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat: chat,
|
||||
...(variables !== null ? { variables } : {}),
|
||||
folder_id: folderId ?? null
|
||||
})
|
||||
})
|
||||
@@ -1197,7 +1203,12 @@ export const getChatAccessGrants = async (token: string, id: string) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateChatById = async (token: string, id: string, chat: object) => {
|
||||
export const updateChatById = async (
|
||||
token: string,
|
||||
id: string,
|
||||
chat: object,
|
||||
variables: object | null = null
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
|
||||
@@ -1208,7 +1219,8 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat: chat
|
||||
chat: chat,
|
||||
...(variables !== null ? { variables } : {})
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
||||
@@ -163,7 +163,14 @@ export const getModels = async (
|
||||
// Remove duplicates
|
||||
const modelsMap = {};
|
||||
for (const model of models) {
|
||||
modelsMap[model.id] = model;
|
||||
const existing = modelsMap[model.id];
|
||||
modelsMap[model.id] = existing
|
||||
? {
|
||||
...existing,
|
||||
...model,
|
||||
info: existing.info ?? model.info
|
||||
}
|
||||
: model;
|
||||
}
|
||||
|
||||
models = Object.values(modelsMap);
|
||||
|
||||
@@ -111,12 +111,14 @@
|
||||
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
|
||||
import NotificationToast from '../NotificationToast.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import Modal from '../common/Modal.svelte';
|
||||
import { isEmbedWindow } from '../common/FullHeightIframe.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import Sidebar from '../icons/Sidebar.svelte';
|
||||
import Image from '../common/Image.svelte';
|
||||
import XMark from '../icons/XMark.svelte';
|
||||
import EmbeddedChatHistoryDropdown from './EmbeddedChatHistoryDropdown.svelte';
|
||||
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
|
||||
|
||||
export let chatIdProp = '';
|
||||
export let embedded = false;
|
||||
@@ -367,9 +369,112 @@
|
||||
let chatFiles = [];
|
||||
let files = [];
|
||||
let params = {};
|
||||
let chatVariables = {};
|
||||
let showChatVariablesModal = false;
|
||||
let loadedChatIdProp = '';
|
||||
let currentDraftKey = '';
|
||||
|
||||
const mergeChatVariableSchemas = (modelIds = []) => {
|
||||
const byKey: Record<string, any> = {};
|
||||
const conflicts: any[] = [];
|
||||
|
||||
for (const modelId of modelIds.filter(Boolean)) {
|
||||
const fields =
|
||||
$models.find((model) => model.id === modelId)?.info?.meta?.chat_variables_schema?.fields ??
|
||||
[];
|
||||
for (const rawField of fields) {
|
||||
const field = {
|
||||
...rawField,
|
||||
type: rawField?.type ?? 'text',
|
||||
required: Boolean(rawField?.required)
|
||||
};
|
||||
if (!field?.key) continue;
|
||||
const { required, ...shape } = field;
|
||||
|
||||
const existing = byKey[field.key];
|
||||
if (!existing) {
|
||||
byKey[field.key] = {
|
||||
field,
|
||||
modelIds: [modelId],
|
||||
shape
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!equal(existing.shape, shape)) {
|
||||
conflicts.push({
|
||||
key: field.key,
|
||||
modelIds: [...existing.modelIds, modelId]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.field = {
|
||||
...existing.field,
|
||||
required: existing.field.required || field.required
|
||||
};
|
||||
existing.modelIds.push(modelId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fields: Object.values(byKey).map((item: any) => item.field),
|
||||
conflicts
|
||||
};
|
||||
};
|
||||
|
||||
const hasValue = (value) => value !== undefined && value !== null && value !== '';
|
||||
|
||||
const getChatVariablesForm = () => {
|
||||
const { fields, conflicts } = mergeChatVariableSchemas(selectedModelIds);
|
||||
const empty =
|
||||
fields.length > 0 &&
|
||||
fields.every((field) => !hasValue(chatVariables?.[field.key]) && !hasValue(field.default));
|
||||
const missing = fields.some(
|
||||
(field) => field.required && !hasValue(chatVariables?.[field.key]) && !hasValue(field.default)
|
||||
);
|
||||
const variables = fields.reduce(
|
||||
(acc, field) => {
|
||||
const { key, ...inputField } = field;
|
||||
acc[key] = {
|
||||
...inputField,
|
||||
default: hasValue(chatVariables?.[key]) ? chatVariables[key] : inputField.default
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
|
||||
return { conflicts, empty, missing, variables };
|
||||
};
|
||||
|
||||
const saveChatVariables = async (values) => {
|
||||
chatVariables = { ...chatVariables, ...values };
|
||||
|
||||
if ($chatId && !$temporaryChatEnabled && !$chatId.startsWith('local:')) {
|
||||
const res = await updateChatById(localStorage.token, $chatId, {}, chatVariables).catch(
|
||||
(err) => {
|
||||
console.error('[chat variables save]', err);
|
||||
toast.error($i18n.t('Failed to save chat variables'));
|
||||
return null;
|
||||
}
|
||||
);
|
||||
if (res) chat = res;
|
||||
}
|
||||
};
|
||||
|
||||
$: chatVariablesForm = getChatVariablesForm();
|
||||
|
||||
let oldSelectedModelIds = [''];
|
||||
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
|
||||
onSelectedModelIdsChange();
|
||||
}
|
||||
|
||||
const onSelectedModelIdsChange = () => {
|
||||
resetInput();
|
||||
oldSelectedModelIds = structuredClone(selectedModelIds);
|
||||
};
|
||||
|
||||
const mergeFiles = (current, incoming) => {
|
||||
const seen = new Set();
|
||||
return [...(incoming ?? []), ...(current ?? [])].filter((file) => {
|
||||
@@ -514,6 +619,7 @@
|
||||
currentId: null
|
||||
};
|
||||
params = {};
|
||||
chatVariables = {};
|
||||
chatFiles = [];
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
@@ -585,16 +691,6 @@
|
||||
initiateOAuthRedirect(nextTool);
|
||||
};
|
||||
|
||||
let oldSelectedModelIds = [''];
|
||||
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
|
||||
onSelectedModelIdsChange();
|
||||
}
|
||||
|
||||
const onSelectedModelIdsChange = () => {
|
||||
resetInput();
|
||||
oldSelectedModelIds = structuredClone(selectedModelIds);
|
||||
};
|
||||
|
||||
const resetInput = async () => {
|
||||
selectedToolIds = [];
|
||||
selectedSkillIds = [];
|
||||
@@ -1703,6 +1799,7 @@
|
||||
|
||||
chatFiles = [];
|
||||
params = {};
|
||||
chatVariables = {};
|
||||
taskIds = null;
|
||||
chatTasks = [];
|
||||
|
||||
@@ -1854,6 +1951,7 @@
|
||||
noteChatDebug('getTagsById completed', { tagCount: tags?.length ?? 0 });
|
||||
|
||||
const chatContent = chat.chat;
|
||||
chatVariables = chat?.variables ?? {};
|
||||
|
||||
if (chatContent) {
|
||||
noteChatDebug('chat payload found', {
|
||||
@@ -2552,6 +2650,15 @@
|
||||
toast.error($i18n.t('Model not selected'));
|
||||
return;
|
||||
}
|
||||
if (chatVariablesForm.conflicts.length > 0) {
|
||||
showChatVariablesModal = true;
|
||||
toast.error($i18n.t('Chat Variables have conflicting model definitions'));
|
||||
return;
|
||||
}
|
||||
if (chatVariablesForm.missing || chatVariablesForm.empty) {
|
||||
showChatVariablesModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
files.length > 0 &&
|
||||
@@ -2968,6 +3075,8 @@
|
||||
|
||||
// Only send terminal_id if the model has terminal capability enabled
|
||||
const terminalEnabled = model.info?.meta?.capabilities?.terminal ?? true;
|
||||
const useChatVariablesFallback =
|
||||
!_chatId || $temporaryChatEnabled || _chatId.startsWith('local:');
|
||||
|
||||
const res = await generateOpenAIChatCompletion(
|
||||
localStorage.token,
|
||||
@@ -3002,6 +3111,7 @@
|
||||
$user?.email
|
||||
)
|
||||
},
|
||||
...(useChatVariablesFallback ? { chat_variables: chatVariables } : {}),
|
||||
model_item: $models.find((m) => m.id === model.id),
|
||||
|
||||
session_id: $socket?.id,
|
||||
@@ -3352,7 +3462,8 @@
|
||||
tags: [],
|
||||
timestamp: Date.now()
|
||||
},
|
||||
$selectedFolder?.id
|
||||
$selectedFolder?.id,
|
||||
chatVariables
|
||||
);
|
||||
|
||||
_chatId = chat.id;
|
||||
@@ -3515,6 +3626,47 @@
|
||||
|
||||
<audio id="audioElement" style="display: none;"></audio>
|
||||
|
||||
{#if chatVariablesForm.conflicts.length > 0}
|
||||
<Modal bind:show={showChatVariablesModal} size="md">
|
||||
<div>
|
||||
<div class="flex justify-between px-4 pt-3 pb-1 dark:text-gray-300">
|
||||
<div class="self-center text-sm font-medium">{$i18n.t('Chat Variables')}</div>
|
||||
<button
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
showChatVariablesModal = false;
|
||||
}}
|
||||
>
|
||||
<XMark className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-5 pb-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div class="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Selected models define incompatible Chat Variables.')}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each chatVariablesForm.conflicts as conflict}
|
||||
<div class="rounded-lg border border-red-200 px-3 py-2 text-xs dark:border-red-900/60">
|
||||
<div class="font-medium text-red-600 dark:text-red-400">{conflict.key}</div>
|
||||
<div class="mt-1 text-gray-500 dark:text-gray-400">
|
||||
{conflict.modelIds.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{:else}
|
||||
<InputVariablesModal
|
||||
bind:show={showChatVariablesModal}
|
||||
title={$i18n.t('Chat Variables')}
|
||||
variables={chatVariablesForm.variables}
|
||||
onSave={saveChatVariables}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<WebSearchConfirmDialog
|
||||
bind:show={showWebSearchConfirm}
|
||||
title={$i18n.t('Use Web Search?')}
|
||||
@@ -3673,7 +3825,8 @@
|
||||
messages: messages,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
null
|
||||
null,
|
||||
chatVariables
|
||||
);
|
||||
|
||||
if (savedChat) {
|
||||
@@ -3819,6 +3972,9 @@
|
||||
}
|
||||
}}
|
||||
onWebSearchToggle={handleWebSearchToggle}
|
||||
on:chatVariables={() => {
|
||||
showChatVariablesModal = true;
|
||||
}}
|
||||
on:submit={async (e) => {
|
||||
clearDraft($chatId);
|
||||
if (e.detail || files.length > 0) {
|
||||
@@ -3895,6 +4051,9 @@
|
||||
messageQueue={$chatRequestQueues[$chatId] ?? []}
|
||||
{chatTasks}
|
||||
onWebSearchToggle={handleWebSearchToggle}
|
||||
on:chatVariables={() => {
|
||||
showChatVariablesModal = true;
|
||||
}}
|
||||
on:submit={async (e) => {
|
||||
clearDraft($chatId);
|
||||
if (e.detail || files.length > 0) {
|
||||
@@ -3930,6 +4089,9 @@
|
||||
{onSelect}
|
||||
{onUpload}
|
||||
onWebSearchToggle={handleWebSearchToggle}
|
||||
on:chatVariables={() => {
|
||||
showChatVariablesModal = true;
|
||||
}}
|
||||
onChange={(data) => {
|
||||
if (!$temporaryChatEnabled) {
|
||||
saveDraft(data);
|
||||
|
||||
@@ -132,6 +132,11 @@
|
||||
|
||||
let selectedModelIds = [];
|
||||
$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
|
||||
$: hasChatVariables = selectedModelIds.some(
|
||||
(modelId) =>
|
||||
($models.find((model) => model.id === modelId)?.info?.meta?.chat_variables_schema?.fields
|
||||
?.length ?? 0) > 0
|
||||
);
|
||||
|
||||
export let history;
|
||||
export let taskIds = null;
|
||||
@@ -2236,6 +2241,22 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if hasChatVariables}
|
||||
<Tooltip content={$i18n.t('Chat Variables')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
id="chat-variables-button"
|
||||
class="flex size-[1.875rem] shrink-0 items-center justify-center rounded-full bg-transparent text-gray-500 transition-colors hover:text-gray-800 focus:outline-hidden dark:text-gray-400 dark:hover:text-gray-100"
|
||||
aria-label={$i18n.t('Chat Variables')}
|
||||
on:click={() => {
|
||||
dispatch('chatVariables');
|
||||
}}
|
||||
>
|
||||
<Knobs className="size-4" strokeWidth="1.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if isActive && prompt === '' && files.length === 0}
|
||||
<div class=" flex items-center">
|
||||
<Tooltip content={$i18n.t('Stop')}>
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
{onChange}
|
||||
{onUpload}
|
||||
{onWebSearchToggle}
|
||||
on:chatVariables
|
||||
on:submit={(e) => {
|
||||
dispatch('submit', e.detail);
|
||||
}}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
import TTSVoiceInput from './TTSVoiceInput.svelte';
|
||||
import AccessControlModal from '../common/AccessControlModal.svelte';
|
||||
import AccessButton from '$lib/components/common/AccessButton.svelte';
|
||||
import { extractInputVariables } from '$lib/utils';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
@@ -113,6 +114,46 @@
|
||||
export let suggestionTags: { name: string }[] = [];
|
||||
let voices: { id: string; name?: string }[] = [];
|
||||
|
||||
const chatVariableKeyRegex = /^[a-z][a-z0-9_]*$/;
|
||||
const getChatVariablesPreview = (prompt: string) => {
|
||||
const variables = extractInputVariables(prompt);
|
||||
const warnings: string[] = [];
|
||||
const seenDefinitions: Record<string, string> = {};
|
||||
const typedRegex = /{{\s*chat\.variables\.([a-zA-Z0-9_.-]+)\s*\|\s*([^}]*)\s*}}/g;
|
||||
|
||||
for (const match of prompt.matchAll(typedRegex)) {
|
||||
const key = match[1];
|
||||
const definition = match[2].trim();
|
||||
if (seenDefinitions[key] && seenDefinitions[key] !== definition) {
|
||||
warnings.push(`${key} has conflicting duplicate definitions`);
|
||||
}
|
||||
seenDefinitions[key] = definition;
|
||||
}
|
||||
|
||||
const fields = Object.entries(variables)
|
||||
.filter(([name]) => name.startsWith('chat.variables.'))
|
||||
.map(([name, field]) => ({ key: name.replace('chat.variables.', ''), ...(field as any) }));
|
||||
|
||||
for (const field of fields) {
|
||||
const key = field.key;
|
||||
if (!chatVariableKeyRegex.test(key)) {
|
||||
warnings.push(`${key} must be lowercase snake case`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
field.type === 'select' &&
|
||||
(!Array.isArray(field.options) || field.options.length === 0)
|
||||
) {
|
||||
warnings.push(`${key} select needs options=[...]`);
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, warnings };
|
||||
};
|
||||
|
||||
$: chatVariablesPreview = getChatVariablesPreview(system ?? '');
|
||||
|
||||
const getBaseModelItems = (models: any[] = []) => {
|
||||
const currentModelId = (model as any)?.id;
|
||||
|
||||
@@ -721,6 +762,44 @@
|
||||
bind:value={system}
|
||||
/>
|
||||
</div>
|
||||
{#if chatVariablesPreview.fields.length > 0 || chatVariablesPreview.warnings.length > 0}
|
||||
<div class="mt-2 border-t border-gray-100/60 pt-2 dark:border-gray-850/60">
|
||||
<div class="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Detected Chat Variables')}
|
||||
</div>
|
||||
{#if chatVariablesPreview.fields.length > 0}
|
||||
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{chatVariablesPreview.fields.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chatVariablesPreview.fields.length > 0}
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1.5 text-xs">
|
||||
{#each chatVariablesPreview.fields as field}
|
||||
<div class="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||
<span class="font-medium">{field.key}</span>
|
||||
<span class="text-gray-400 dark:text-gray-600">{field.type}</span>
|
||||
{#if field.required}
|
||||
<span class="text-amber-600 dark:text-amber-400">required</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if chatVariablesPreview.warnings.length > 0}
|
||||
<div
|
||||
class="mt-2 flex flex-col gap-1 text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{#each chatVariablesPreview.warnings as warning}
|
||||
<div>{warning}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex h-7 w-full justify-between">
|
||||
|
||||
Reference in New Issue
Block a user