This commit is contained in:
Timothy Jaeryang Baek 2026-10-21 09:15:15 +09:00
parent 1184d36c13
commit b4a58be193
9 changed files with 189 additions and 30 deletions

View file

@ -852,6 +852,18 @@ class ChatTable:
except Exception:
return
async def update_chat_params_by_id(self, id: str, params: dict) -> ChatModel | None:
async with self._chat_transaction(id) as (_, chat):
if chat is None:
return None
stored = chat.chat or {}
chat.chat = {
**stored,
'params': self._clean_null_bytes({**(stored.get('params') or {}), **params}),
}
return ChatModel.model_validate(chat)
async def update_chat_variables_by_id(
self,
id: str,

View file

@ -1482,6 +1482,39 @@ async def update_chat_by_id(
)
############################
# UpdateChatParams
############################
@router.patch('/{id}/params', response_model=dict)
async def update_chat_params(
request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if not await Chats.get_chat_by_id_and_user_id(id, user.id, db=db):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = await Chats.update_chat_params_by_id(id, form_data)
if chat is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
await publish_event(
request,
EVENTS.CHAT_UPDATED,
actor=user,
subject_id=id,
data={'title': chat.title},
)
return chat.chat['params']
############################
# UpdateChatMessageById
############################

View file

@ -1315,6 +1315,23 @@ export const updateChatById = async (
return res;
};
export const updateChatParams = async (
token: string,
id: string,
params: Record<string, unknown>
) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/params`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify(params)
});
if (!res.ok) throw getErrorDetail(await res.json());
return res.json();
};
export const compactChatById = async (token: string, id: string, model?: string | null) => {
let error = null;

View file

@ -93,6 +93,7 @@
importChats,
resolveChatMessageToolCall,
updateChatById,
updateChatParams,
updateChatFolderIdById
} from '$lib/apis/chats';
import type { FilesystemFile } from '$lib/apis/terminal';
@ -518,20 +519,13 @@
tool_approval_mode
}
});
await saveParam('tool_approval_mode', tool_approval_mode);
await updateUserSettings(localStorage.token, { ui: { params: $settings.params } }).catch(
(err) => {
console.error('[tool permissions settings]', err);
}
);
if (!isShared && $chatId && !$temporaryChatEnabled && !isTemporaryChatId($chatId)) {
const res = await updateChatById(localStorage.token, $chatId, { params }).catch((err) => {
console.error('[tool permissions chat]', err);
return null;
});
if (res) chat = res;
}
if (tool_approval_mode === 'full') {
if (isTemporaryChatId($chatId)) {
if (browserApprovalMode !== 'full') return;
@ -882,7 +876,7 @@
}
let saveControlsTimer;
$: if (!loading && !$temporaryChatEnabled && $chatId && params && chatFiles) {
$: if (!loading && !$temporaryChatEnabled && $chatId && chatFiles) {
clearTimeout(saveControlsTimer);
saveControlsTimer = setTimeout(saveControls, 400);
}
@ -3216,6 +3210,29 @@
const chatActionHandler = async (_chatId, actionId, modelId, responseMessageId, event = null) => {
const messages = createMessagesList(history, responseMessageId);
let changed = false;
const actionSocket = $socket;
const onEvent = (event) => {
if (event.chat_id !== _chatId || event.message_id !== responseMessageId) return;
const { type, data } = event.data ?? {};
// These events are UI-only or already persisted by get_event_emitter.
if (
[
'notification',
'confirmation',
'input',
'status',
'message',
'replace',
'files',
'embeds'
].includes(type) ||
(['source', 'citation'].includes(type) && data?.type == null)
)
return;
changed = true;
};
actionSocket?.on('events', onEvent);
const res = await chatAction(localStorage.token, actionId, {
model: modelId,
@ -3232,16 +3249,23 @@
chat_id: _chatId,
session_id: $socket?.id,
id: responseMessageId
}).catch((error) => {
toast.error(`${error}`);
messages.at(-1).error = { content: error };
return null;
});
})
.catch((error) => {
toast.error(`${error}`);
if ($chatId === _chatId && history.messages[responseMessageId]) {
history.messages[responseMessageId].error = { content: error };
changed = true;
}
return null;
})
.finally(() => actionSocket?.off('events', onEvent));
if (res !== null && res.messages) {
if ($chatId !== _chatId) return;
if (res?.messages) {
// Update chat history with the new messages
for (const message of res.messages) {
history.messages[message.id] = {
const updated = {
...history.messages[message.id],
...(history.messages[message.id].content !== message.content
? { originalContent: history.messages[message.id].content }
@ -3251,16 +3275,19 @@
: {}),
...message
};
if (!equal(history.messages[message.id], updated)) {
history.messages[message.id] = updated;
changed = true;
}
}
}
if ($chatId == _chatId) {
if (changed) {
if (!$temporaryChatEnabled) {
chat = await updateChatById(localStorage.token, _chatId, {
models: selectedModels,
messages: messages,
messages: createMessagesList(history, responseMessageId),
history: history,
params: params,
files: chatFiles
});
@ -4465,9 +4492,7 @@
// chat completion request. Files are now persisted
// by the backend at chat creation time.
if (Object.keys(params).length > 0) {
await updateChatById(localStorage.token, res.chat_id, {
params: params
});
await updateChatParams(localStorage.token, res.chat_id, params);
}
}
}
@ -4751,21 +4776,26 @@
models: selectedModels,
history: history,
messages: createMessagesList(history, history.currentId),
params: params,
files: chatFiles
});
}
}
};
const saveParam = async (key: string, value: unknown) => {
if (isShared || !$chatId || $temporaryChatEnabled || isTemporaryChatId($chatId)) return;
await updateChatParams(localStorage.token, $chatId, { [key]: value }).catch((error) => {
toast.error(`${error}`);
});
};
const saveControls = async () => {
if (isShared) return;
if (!$chatId || $temporaryChatEnabled) return;
const loaded = chat?.chat ?? {};
if (equal(params, loaded.params ?? {}) && equal(chatFiles, loaded.files ?? [])) return;
if (equal(chatFiles, loaded.files ?? [])) return;
const res = await updateChatById(localStorage.token, $chatId, {
params,
files: chatFiles
}).catch((err) => {
console.error('[controls autosave]', err);
@ -5490,6 +5520,7 @@
bind:history
bind:chatFiles
bind:params
onParamChange={saveParam}
bind:files
chatId={$chatId}
chatUser={chatOwner}

View file

@ -40,6 +40,7 @@
export let chatFiles = [];
export let params = {};
export let onParamChange: (key: string, value: unknown) => void = () => {};
export let bridge: import('$lib/utils/realtime').RealtimeCall;
export let callMode = 'current';
@ -307,7 +308,7 @@
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
<Controls embed={true} {models} bind:chatFiles bind:params {onParamChange} />
{/if}
</div>
</div>
@ -437,7 +438,7 @@
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav overlay={dragged} />
{:else}
<Controls embed={true} {models} bind:chatFiles bind:params />
<Controls embed={true} {models} bind:chatFiles bind:params {onParamChange} />
{/if}
</div>
</div>

View file

@ -12,7 +12,8 @@
import { user, settings } from '$lib/stores';
export let models = [];
export let chatFiles = [];
export let params = {};
export let params: Record<string, any> = {};
export let onParamChange: (key: string, value: unknown) => void = () => {};
export let embed = false;
// Persist collapsible section open/close state
@ -118,6 +119,7 @@
<div class="pt-1 pb-1" slot="content">
<textarea
bind:value={params.system}
on:change={() => onParamChange('system', params.system)}
class={($settings?.highContrastMode ?? false)
? systemPromptTextareaClass
: compactSystemPromptTextareaClass}
@ -139,7 +141,12 @@
>
<div class="pt-1 pb-1 text-xs" slot="content">
<div>
<AdvancedParams admin={$user?.role === 'admin'} custom={true} bind:params />
<AdvancedParams
admin={$user?.role === 'admin'}
custom={true}
bind:params
{onParamChange}
/>
</div>
</div>
</Collapsible>

View file

@ -10,6 +10,7 @@
const i18n = getContext<Writable<i18nType>>('i18n');
export let onChange: (params: any) => void = () => {};
export let onParamChange: (key: string, value: unknown) => void = () => {};
export let admin = false;
export let custom = false;
@ -89,12 +90,14 @@
{max}
step={rangeStep}
bind:value={params[key]}
on:change={() => onParamChange(key, params[key] ?? null)}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
<div>
<input
bind:value={params[key]}
on:change={() => onParamChange(key, params[key] ?? null)}
type="number"
aria-label={label}
class=" bg-transparent text-center w-14"
@ -132,6 +135,7 @@
: params.stream_response
? false
: null;
onParamChange('stream_response', params.stream_response);
}}
type="button"
>
@ -166,6 +170,7 @@
on:click={() => {
params.stream_delta_chunk_size =
(params?.stream_delta_chunk_size ?? null) === null ? 1 : null;
onParamChange('stream_delta_chunk_size', params.stream_delta_chunk_size);
}}
>
{#if (params?.stream_delta_chunk_size ?? null) === null}
@ -210,6 +215,7 @@
on:click={() => {
params.compact_token_threshold =
(params?.compact_token_threshold ?? null) === null ? 80000 : null;
onParamChange('compact_token_threshold', params.compact_token_threshold);
}}
>
{#if (params?.compact_token_threshold ?? null) === null}
@ -232,6 +238,8 @@
)}
placeholder={$i18n.t('Enter token threshold')}
bind:value={params.compact_token_threshold}
on:change={() =>
onParamChange('compact_token_threshold', params.compact_token_threshold ?? null)}
autocomplete="off"
min="1"
step="1"
@ -264,6 +272,7 @@
} else {
params.function_calling = null;
}
onParamChange('function_calling', params.function_calling);
}}
type="button"
>
@ -304,6 +313,7 @@
} else {
params.reasoning_tags = null;
}
onParamChange('reasoning_tags', params.reasoning_tags);
}}
>
{#if (params?.reasoning_tags ?? null) === null}
@ -328,6 +338,7 @@
aria-label={$i18n.t('Start Tag')}
placeholder={$i18n.t('Start Tag')}
bind:value={params.reasoning_tags[0]}
on:change={() => onParamChange('reasoning_tags', params.reasoning_tags ?? null)}
autocomplete="off"
/>
</div>
@ -339,6 +350,7 @@
aria-label={$i18n.t('End Tag')}
placeholder={$i18n.t('End Tag')}
bind:value={params.reasoning_tags[1]}
on:change={() => onParamChange('reasoning_tags', params.reasoning_tags ?? null)}
autocomplete="off"
/>
</div>
@ -364,6 +376,7 @@
type="button"
on:click={() => {
params.seed = (params?.seed ?? null) === null ? 0 : null;
onParamChange('seed', params.seed);
}}
>
{#if (params?.seed ?? null) === null}
@ -384,6 +397,7 @@
aria-label={$i18n.t('settings.personal.general.parameters.seed.label')}
placeholder={$i18n.t('Enter Seed')}
bind:value={params.seed}
on:change={() => onParamChange('seed', params.seed ?? null)}
autocomplete="off"
min="0"
/>
@ -410,6 +424,7 @@
type="button"
on:click={() => {
params.stop = (params?.stop ?? null) === null ? '' : null;
onParamChange('stop', params.stop);
}}
>
{#if (params?.stop ?? null) === null}
@ -430,6 +445,7 @@
aria-label={$i18n.t('settings.personal.general.parameters.stopSequence.label')}
placeholder={$i18n.t('Enter stop sequence')}
bind:value={params.stop}
on:change={() => onParamChange('stop', params.stop ?? null)}
autocomplete="off"
/>
</div>
@ -454,6 +470,7 @@
type="button"
on:click={() => {
params.temperature = (params?.temperature ?? null) === null ? 0.8 : null;
onParamChange('temperature', params.temperature);
}}
>
{#if (params?.temperature ?? null) === null}
@ -493,6 +510,7 @@
type="button"
on:click={() => {
params.reasoning_effort = (params?.reasoning_effort ?? null) === null ? 'medium' : null;
onParamChange('reasoning_effort', params.reasoning_effort);
}}
>
{#if (params?.reasoning_effort ?? null) === null}
@ -513,6 +531,7 @@
aria-label={$i18n.t('settings.personal.general.parameters.reasoningEffort.label')}
placeholder={$i18n.t('Enter reasoning effort')}
bind:value={params.reasoning_effort}
on:change={() => onParamChange('reasoning_effort', params.reasoning_effort ?? null)}
autocomplete="off"
/>
</div>
@ -539,6 +558,7 @@
type="button"
on:click={() => {
params.logit_bias = (params?.logit_bias ?? null) === null ? '' : null;
onParamChange('logit_bias', params.logit_bias);
}}
>
{#if (params?.logit_bias ?? null) === null}
@ -561,6 +581,7 @@
'Enter comma-separated "token:bias_value" pairs (example: 5432:100, 413:-100)'
)}
bind:value={params.logit_bias}
on:change={() => onParamChange('logit_bias', params.logit_bias ?? null)}
autocomplete="off"
/>
</div>
@ -588,6 +609,7 @@
type="button"
on:click={() => {
params.max_tokens = (params?.max_tokens ?? null) === null ? 128 : null;
onParamChange('max_tokens', params.max_tokens);
}}
>
{#if (params?.max_tokens ?? null) === null}
@ -631,6 +653,7 @@
type="button"
on:click={() => {
params.top_k = (params?.top_k ?? null) === null ? 40 : null;
onParamChange('top_k', params.top_k);
}}
>
{#if (params?.top_k ?? null) === null}
@ -653,6 +676,7 @@
step="1"
value={params.top_k}
on:input={setTopK}
on:change={() => onParamChange('top_k', params.top_k ?? null)}
class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
</div>
@ -666,6 +690,7 @@
max="1000"
step="1"
on:input={setTopK}
on:change={() => onParamChange('top_k', params.top_k ?? null)}
/>
</div>
</div>
@ -692,6 +717,7 @@
type="button"
on:click={() => {
params.top_p = (params?.top_p ?? null) === null ? 0.9 : null;
onParamChange('top_p', params.top_p);
}}
>
{#if (params?.top_p ?? null) === null}
@ -733,6 +759,7 @@
type="button"
on:click={() => {
params.min_p = (params?.min_p ?? null) === null ? 0.0 : null;
onParamChange('min_p', params.min_p);
}}
>
{#if (params?.min_p ?? null) === null}
@ -775,6 +802,7 @@
type="button"
on:click={() => {
params.frequency_penalty = (params?.frequency_penalty ?? null) === null ? 1.1 : null;
onParamChange('frequency_penalty', params.frequency_penalty);
}}
>
{#if (params?.frequency_penalty ?? null) === null}
@ -817,6 +845,7 @@
type="button"
on:click={() => {
params.presence_penalty = (params?.presence_penalty ?? null) === null ? 0.0 : null;
onParamChange('presence_penalty', params.presence_penalty);
}}
>
{#if (params?.presence_penalty ?? null) === null}
@ -856,6 +885,7 @@
type="button"
on:click={() => {
params.mirostat = (params?.mirostat ?? null) === null ? 0 : null;
onParamChange('mirostat', params.mirostat);
}}
>
{#if (params?.mirostat ?? null) === null}
@ -898,6 +928,7 @@
type="button"
on:click={() => {
params.mirostat_eta = (params?.mirostat_eta ?? null) === null ? 0.1 : null;
onParamChange('mirostat_eta', params.mirostat_eta);
}}
>
{#if (params?.mirostat_eta ?? null) === null}
@ -940,6 +971,7 @@
type="button"
on:click={() => {
params.mirostat_tau = (params?.mirostat_tau ?? null) === null ? 5.0 : null;
onParamChange('mirostat_tau', params.mirostat_tau);
}}
>
{#if (params?.mirostat_tau ?? null) === null}
@ -980,6 +1012,7 @@
type="button"
on:click={() => {
params.repeat_last_n = (params?.repeat_last_n ?? null) === null ? 64 : null;
onParamChange('repeat_last_n', params.repeat_last_n);
}}
>
{#if (params?.repeat_last_n ?? null) === null}
@ -1023,6 +1056,7 @@
type="button"
on:click={() => {
params.tfs_z = (params?.tfs_z ?? null) === null ? 1 : null;
onParamChange('tfs_z', params.tfs_z);
}}
>
{#if (params?.tfs_z ?? null) === null}
@ -1065,6 +1099,7 @@
type="button"
on:click={() => {
params.repeat_penalty = (params?.repeat_penalty ?? null) === null ? 1.1 : null;
onParamChange('repeat_penalty', params.repeat_penalty);
}}
>
{#if (params?.repeat_penalty ?? null) === null}
@ -1107,6 +1142,7 @@
type="button"
on:click={() => {
params.use_mmap = (params?.use_mmap ?? null) === null ? true : null;
onParamChange('use_mmap', params.use_mmap);
}}
>
{#if (params?.use_mmap ?? null) === null}
@ -1126,6 +1162,7 @@
<div class=" pr-2">
<Switch
bind:state={params.use_mmap}
on:change={(event) => onParamChange('use_mmap', event.detail)}
ariaLabel={$i18n.t('settings.personal.general.parameters.useMmap.label')}
/>
</div>
@ -1153,6 +1190,7 @@
type="button"
on:click={() => {
params.use_mlock = (params?.use_mlock ?? null) === null ? true : null;
onParamChange('use_mlock', params.use_mlock);
}}
>
{#if (params?.use_mlock ?? null) === null}
@ -1173,6 +1211,7 @@
<div class=" pr-2">
<Switch
bind:state={params.use_mlock}
on:change={(event) => onParamChange('use_mlock', event.detail)}
ariaLabel={$i18n.t('settings.personal.general.parameters.useMlock.label')}
/>
</div>
@ -1207,6 +1246,7 @@
} else {
params.think = null;
}
onParamChange('think', params.think);
}}
type="button"
>
@ -1232,6 +1272,7 @@
aria-label={`${$i18n.t('settings.personal.general.parameters.think.label')} (${$i18n.t('settings.personal.general.parameters.ollama.label')})`}
placeholder={$i18n.t("e.g. 'low', 'medium', 'high'")}
bind:value={params.think}
on:change={() => onParamChange('think', params.think ?? null)}
autocomplete="off"
/>
</div>
@ -1257,6 +1298,7 @@
class="p-1 px-3 text-xs flex rounded-sm transition font-normal text-gray-900 dark:text-gray-100"
on:click={() => {
params.format = (params?.format ?? null) === null ? 'json' : null;
onParamChange('format', params.format);
}}
type="button"
>
@ -1276,6 +1318,7 @@
ariaLabel={`${$i18n.t('settings.personal.general.parameters.format.label')} (${$i18n.t('settings.personal.general.parameters.ollama.label')})`}
placeholder={$i18n.t('e.g. "json" or a JSON schema')}
bind:value={params.format}
on:change={() => onParamChange('format', params.format ?? null)}
/>
</div>
{/if}
@ -1303,6 +1346,7 @@
type="button"
on:click={() => {
params.num_keep = (params?.num_keep ?? null) === null ? 24 : null;
onParamChange('num_keep', params.num_keep);
}}
>
{#if (params?.num_keep ?? null) === null}
@ -1347,6 +1391,7 @@
type="button"
on:click={() => {
params.num_ctx = (params?.num_ctx ?? null) === null ? 2048 : null;
onParamChange('num_ctx', params.num_ctx);
}}
>
{#if (params?.num_ctx ?? null) === null}
@ -1393,6 +1438,7 @@
type="button"
on:click={() => {
params.num_batch = (params?.num_batch ?? null) === null ? 512 : null;
onParamChange('num_batch', params.num_batch);
}}
>
{#if (params?.num_batch ?? null) === null}
@ -1440,6 +1486,7 @@
type="button"
on:click={() => {
params.num_thread = (params?.num_thread ?? null) === null ? 2 : null;
onParamChange('num_thread', params.num_thread);
}}
>
{#if (params?.num_thread ?? null) === null}
@ -1485,6 +1532,7 @@
type="button"
on:click={() => {
params.num_gpu = (params?.num_gpu ?? null) === null ? 0 : null;
onParamChange('num_gpu', params.num_gpu);
}}
>
{#if (params?.num_gpu ?? null) === null}
@ -1528,6 +1576,7 @@
class="p-1 px-3 text-xs flex rounded-sm transition font-normal text-gray-900 dark:text-gray-100"
on:click={() => {
params.keep_alive = (params?.keep_alive ?? null) === null ? '5m' : null;
onParamChange('keep_alive', params.keep_alive);
}}
type="button"
>
@ -1548,13 +1597,17 @@
aria-label={`${$i18n.t('settings.personal.general.parameters.keepAlive.label')} (${$i18n.t('settings.personal.general.parameters.ollama.label')})`}
placeholder={$i18n.t("e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.")}
bind:value={params.keep_alive}
on:change={() => onParamChange('keep_alive', params.keep_alive ?? null)}
/>
</div>
{/if}
</div>
{#if custom && admin}
<CustomParams bind:value={params.custom_params} />
<CustomParams
bind:value={params.custom_params}
onChange={(value) => onParamChange('custom_params', value)}
/>
{/if}
{/if}
</div>

View file

@ -8,6 +8,7 @@
const i18n = getContext<Writable<I18n>>('i18n');
export let compact = false;
export let value: Record<string, unknown> = {};
export let onChange: (value: Record<string, unknown>) => void = () => {};
const id = `custom-params-${uuidv4()}`;
// Suggestions only; support depends on the provider and model.
@ -94,6 +95,7 @@
value[name] = value[key];
delete value[key];
value = { ...value };
onChange(value);
}
}}
/>
@ -113,6 +115,7 @@
placeholder={compact ? $i18n.t('e.g. 0.7') : $i18n.t('Custom Parameter Value')}
value={typeof value[key] === 'object' ? JSON.stringify(value[key]) : value[key]}
on:input={(event) => (value = { ...value, [key]: event.currentTarget.value })}
on:change={() => onChange(value)}
/>
{#if options.length}
<datalist id={`${id}-values-${index}`}>
@ -131,6 +134,7 @@
on:click={() => {
delete value[key];
value = { ...value };
onChange(value);
}}
>
{#if compact}<XMark className="size-3" />{:else}{$i18n.t('Remove')}{/if}

View file

@ -95,4 +95,5 @@
resize();
}}
on:blur={onBlur}
on:change
></textarea>