This commit is contained in:
Timothy Jaeryang Baek
2026-07-24 00:47:12 -04:00
parent de939a6562
commit 8cbb7f765c
11 changed files with 580 additions and 17 deletions
+15 -3
View File
@@ -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) => {
+8 -1
View File
@@ -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);
+174 -12
View File
@@ -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">