This commit is contained in:
Timothy Jaeryang Baek 2026-10-14 18:49:51 +04:00
parent bcf540a989
commit 00f12c0e47
11 changed files with 406 additions and 288 deletions

View file

@ -1657,6 +1657,13 @@ AUDIO_REALTIME_OPENAI_API_KEY = os.getenv('AUDIO_REALTIME_OPENAI_API_KEY', '')
AUDIO_REALTIME_MODEL = os.getenv('AUDIO_REALTIME_MODEL', 'gpt-realtime-2.1-mini')
AUDIO_REALTIME_VOICE = os.getenv('AUDIO_REALTIME_VOICE', 'marin')
AUDIO_REALTIME_TRANSCRIPTION_MODEL = os.getenv('AUDIO_REALTIME_TRANSCRIPTION_MODEL', 'gpt-transcribe')
audio_realtime_input_params = os.getenv('AUDIO_REALTIME_INPUT_PARAMS', '')
try:
audio_realtime_input_params = JSONCodec.loads(audio_realtime_input_params)
except JSONCodec.JSONDecodeError:
audio_realtime_input_params = None
AUDIO_REALTIME_INPUT_PARAMS = audio_realtime_input_params if isinstance(audio_realtime_input_params, dict) else None
REALTIME_CALL_PROMPT_TEMPLATE = os.getenv('REALTIME_CALL_PROMPT_TEMPLATE')
AUDIO_TTS_SPLIT_ON = os.getenv('AUDIO_TTS_SPLIT_ON', 'punctuation')
@ -3136,6 +3143,7 @@ DEFAULT_CONFIG = {
'audio.realtime.model': AUDIO_REALTIME_MODEL,
'audio.realtime.voice': AUDIO_REALTIME_VOICE,
'audio.realtime.transcription_model': AUDIO_REALTIME_TRANSCRIPTION_MODEL,
'audio.realtime.input_params': AUDIO_REALTIME_INPUT_PARAMS,
'audio.realtime.prompt_template': REALTIME_CALL_PROMPT_TEMPLATE,
'audio.tts.engine': AUDIO_TTS_ENGINE,
'audio.tts.model': AUDIO_TTS_MODEL,

View file

@ -0,0 +1,34 @@
"""add missing auth rows
Revision ID: 0bd252b206fb
Revises: c92da83b017f
"""
import sqlalchemy as sa
from alembic import op
revision = '0bd252b206fb'
down_revision = 'c92da83b017f'
branch_labels = None
depends_on = None
def upgrade() -> None:
user = sa.table('user', sa.column('id', sa.Text), sa.column('email', sa.Text))
auth = sa.table(
'auth',
sa.column('id', sa.Text),
sa.column('email', sa.Text),
sa.column('password', sa.Text),
sa.column('active', sa.Boolean),
)
# An empty password hash never verifies; existing credentials remain untouched.
missing = sa.select(user.c.id, user.c.email, sa.literal(''), sa.true()).where(
~sa.exists().where(auth.c.id == user.c.id)
)
op.execute(auth.insert().from_select(['id', 'email', 'password', 'active'], missing))
def downgrade() -> None:
# Backfilled accounts may have acquired credentials or MFA state since the upgrade.
pass

View file

@ -31,6 +31,7 @@ DICT_CONFIG_KEY_ALIASES = {
'image_generation.automatic1111.api_params': ('AUTOMATIC1111_PARAMS',),
'image_generation.openai.params': ('IMAGES_OPENAI_API_PARAMS',),
'audio.tts.openai.params': ('AUDIO_TTS_OPENAI_PARAMS',),
'audio.realtime.input_params': ('AUDIO_REALTIME_INPUT_PARAMS',),
'models.default_metadata': ('DEFAULT_MODEL_METADATA',),
'models.default_params': ('DEFAULT_MODEL_PARAMS',),
'task.model.params': ('TASK_MODEL_PARAMS',),

View file

@ -814,6 +814,10 @@ class UsersTable:
return None
for key, value in updated.items():
setattr(user, key, value)
if 'email' in updated:
from open_webui.models.auths import Auth
await session.execute(update(Auth).where(Auth.id == id).values(email=updated['email']))
await session.commit()
return UserModel.model_validate(user)

View file

@ -108,6 +108,7 @@ REALTIME_CONFIG_KEYS = {
'MODEL': 'audio.realtime.model',
'VOICE': 'audio.realtime.voice',
'TRANSCRIPTION_MODEL': 'audio.realtime.transcription_model',
'INPUT_PARAMS': 'audio.realtime.input_params',
'REALTIME_CALL_PROMPT_TEMPLATE': 'audio.realtime.prompt_template',
}
@ -302,6 +303,7 @@ class RealtimeConfigForm(BaseModel):
MODEL: str = Field(default='gpt-realtime-2.1-mini', min_length=1, max_length=200)
VOICE: str = Field(default='marin', min_length=1, max_length=200)
TRANSCRIPTION_MODEL: str = Field(default='gpt-transcribe', min_length=1, max_length=200)
INPUT_PARAMS: Optional[dict] = None
REALTIME_CALL_PROMPT_TEMPLATE: Optional[str] = None

View file

@ -384,6 +384,7 @@ async def realtime_call(ws: WebSocket):
'audio.realtime.model',
'audio.realtime.voice',
'audio.realtime.transcription_model',
'audio.realtime.input_params',
'audio.realtime.prompt_template',
'user.permissions',
)
@ -458,7 +459,6 @@ async def realtime_call(ws: WebSocket):
'audio': {
'input': {
'format': {'type': 'audio/pcm', 'rate': 24000},
'noise_reduction': {'type': 'near_field'},
'transcription': {'model': config['audio.realtime.transcription_model']},
# Wait for the finalized transcript before requesting a response. This gives
# every function call an unambiguous input_item_id, even during barge-in.
@ -468,6 +468,7 @@ async def realtime_call(ws: WebSocket):
'interrupt_response': False,
'create_response': False,
},
**(config.get('audio.realtime.input_params') or {}),
},
'output': {'format': {'type': 'audio/pcm', 'rate': 24000}, 'voice': voice},
},

View file

@ -20,12 +20,14 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.env import SCIM_AUTH_PROVIDER
from open_webui.internal.db import get_async_session
from open_webui.models.auths import Auths
from open_webui.models.groups import GroupModel, Groups, GroupHierarchyError
from open_webui.models.users import UserModel, Users
from open_webui.utils.auth import (
decode_token,
get_admin_user,
get_current_user,
get_password_hash,
get_verified_user,
)
from pydantic import BaseModel, ConfigDict, Field
@ -620,9 +622,6 @@ async def create_user(
detail=f'User with email {email} already exists',
)
# Create user
user_id = str(uuid.uuid4())
# Parse name if provided
name = user_data.displayName
if user_data.name:
@ -636,10 +635,10 @@ async def create_user(
if user_data.photos and len(user_data.photos) > 0:
profile_image = user_data.photos[0].value
new_user = await Users.insert_new_user(
id=user_id,
name=name,
new_user = await Auths.insert_new_auth(
email=email,
password=await get_password_hash(str(uuid.uuid4())),
name=name,
profile_image_url=profile_image,
role='user' if user_data.active else 'pending',
db=db,
@ -651,7 +650,7 @@ async def create_user(
detail='Failed to create user',
)
new_user = await Users.update_user_scim_by_id(user_id, get_scim_provider(), user_data.externalId, db=db)
new_user = await Users.update_user_scim_by_id(new_user.id, get_scim_provider(), user_data.externalId, db=db)
if not new_user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@ -871,7 +870,7 @@ async def delete_user(
detail=f'User {user_id} not found',
)
success = await Users.delete_user_by_id(user_id, db=db)
success = await Auths.delete_auth_by_id(user_id, db=db)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View file

@ -36,6 +36,7 @@ export type RealtimeConfig = {
MODEL: string;
VOICE: string;
TRANSCRIPTION_MODEL: string;
INPUT_PARAMS: Record<string, unknown> | null;
REALTIME_CALL_PROMPT_TEMPLATE: string | null;
};

View file

@ -68,6 +68,14 @@
}
};
type TokenUsage = { input_tokens: number; output_tokens: number; total_tokens: number };
$: tokenColumns = [
{ key: 'input_tokens', label: $i18n.t('Input') },
{ key: 'output_tokens', label: $i18n.t('Output') },
{ key: 'total_tokens', label: $i18n.t('Total') }
] as const;
let selectedLeaderboard = 'models';
// Data
let summary = { total_messages: 0, total_chats: 0, total_models: 0, total_users: 0 };
let modelStats: Array<{
@ -77,12 +85,11 @@
unique_chats?: number;
name?: string;
}> = [];
let userStats: Array<{ user_id: string; name?: string; email?: string; count: number }> = [];
let userStats: Array<
{ user_id: string; name?: string; email?: string; count: number } & Partial<TokenUsage>
> = [];
let dailyStats: Array<{ date: string; models: Record<string, number> }> = [];
let tokenStats: Record<
string,
{ input_tokens: number; output_tokens: number; total_tokens: number }
> = {};
let tokenStats: Record<string, TokenUsage> = {};
let totalTokens = { input: 0, output: 0, total: 0 };
let tokenTooltip: Instance | null = null;
@ -188,9 +195,13 @@
if (modelOrderBy === 'name') {
return modelDirection === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
}
if (modelOrderBy === 'tokens') {
const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0;
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
if (
modelOrderBy === 'input_tokens' ||
modelOrderBy === 'output_tokens' ||
modelOrderBy === 'total_tokens'
) {
const aTokens = tokenStats[a.model_id]?.[modelOrderBy] ?? 0;
const bTokens = tokenStats[b.model_id]?.[modelOrderBy] ?? 0;
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
if (modelOrderBy === 'users') {
@ -212,9 +223,13 @@
const nameB = b.name || b.user_id;
return userDirection === 'asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
}
if (userOrderBy === 'tokens') {
const aTokens = a.total_tokens ?? 0;
const bTokens = b.total_tokens ?? 0;
if (
userOrderBy === 'input_tokens' ||
userOrderBy === 'output_tokens' ||
userOrderBy === 'total_tokens'
) {
const aTokens = a[userOrderBy] ?? 0;
const bTokens = b[userOrderBy] ?? 0;
return userDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
return userDirection === 'asc' ? a.count - b.count : b.count - a.count;
@ -371,278 +386,299 @@
<Spinner className="size-5" />
</div>
{:else}
<div class="grid md:grid-cols-2 gap-4">
<!-- Model Usage Table -->
<div>
<div class="text-xs font-normal text-gray-700 dark:text-gray-300 mb-1 px-0.5">
{$i18n.t('Model Usage')}
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleModelSort('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Model')}
{#if modelOrderBy === 'name'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if modelOrderBy === 'count'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('users')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Users')}
{#if modelOrderBy === 'users'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('chats')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Chats')}
{#if modelOrderBy === 'chats'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('tokens')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Tokens')}
{#if modelOrderBy === 'tokens'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-16"
on:click={() => toggleModelSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
%
{#if modelOrderBy === 'percentage'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
{#each sortedModels as model, idx (model.model_id)}
<tr
class="dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
on:click={() => {
selectedModel = { id: model.model_id, name: model.name };
showModelModal = true;
}}
>
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
e.target.src = '/favicon.png';
}}
/>
<span class="truncate max-w-[9.375rem]">{model.name}</span>
</div>
</td>
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td>
<td class="px-3 py-1 text-right">{(model.unique_users ?? 0).toLocaleString()}</td>
<td class="px-3 py-1 text-right">{(model.unique_chats ?? 0).toLocaleString()}</td>
<td class="px-3 py-1 text-right"
>{formatNumber(tokenStats[model.model_id]?.total_tokens ?? 0)}</td
<div class="mb-2">
<div class="inline-flex rounded-full bg-gray-100/80 p-0.5 dark:bg-gray-800/80 backdrop-blur-sm">
{#each [{ key: 'models', label: $i18n.t('Models') }, { key: 'users', label: $i18n.t('Users') }] as tab}
<button
type="button"
aria-pressed={selectedLeaderboard === tab.key}
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-normal {selectedLeaderboard ===
tab.key
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
on:click={() => (selectedLeaderboard = tab.key)}>{tab.label}</button
>
{/each}
</div>
</div>
<div class="min-w-0">
{#if selectedLeaderboard === 'models'}
<!-- Model Usage Table -->
<div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleModelSort('name')}
>
<td class="px-3 py-1 text-right text-gray-400">
{totalModelMessages > 0
? ((model.count / totalModelMessages) * 100).toFixed(1)
: 0}%
</td>
</tr>
{/each}
{#if sortedModels.length === 0}
<tr
><td colspan="7" class="px-3 py-2 text-center text-gray-400"
>{$i18n.t('No data')}</td
></tr
>
{/if}
</tbody>
</table>
</div>
</div>
<!-- User Activity Table -->
<div>
<div class="text-xs font-normal text-gray-700 dark:text-gray-300 mb-1 px-0.5">
{$i18n.t('User Activity')}
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleUserSort('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('User')}
{#if userOrderBy === 'name'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleUserSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if userOrderBy === 'count'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleUserSort('tokens')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Tokens')}
{#if userOrderBy === 'tokens'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
{#each sortedUsers as user, idx (user.user_id)}
<tr class="dark:border-gray-850 text-xs">
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
alt={user.name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/user.png';
}}
/>
<span class="truncate max-w-[9.375rem]"
>{user.name || user.email || user.user_id.substring(0, 8)}</span
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Model')}
{#if modelOrderBy === 'name'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</td>
<td class="px-3 py-1 text-right">{user.count.toLocaleString()}</td>
<td class="px-3 py-1 text-right">{formatNumber(user.total_tokens ?? 0)}</td>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if modelOrderBy === 'count'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('users')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Users')}
{#if modelOrderBy === 'users'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('chats')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Chats')}
{#if modelOrderBy === 'chats'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
{#each tokenColumns as column}
<th
scope="col"
title={`${column.label} ${$i18n.t('tokens')}`}
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort(column.key)}
>
<div class="flex gap-1.5 items-center justify-end">
{column.label}
{#if modelOrderBy === column.key}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
{/each}
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-16"
on:click={() => toggleModelSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
%
{#if modelOrderBy === 'percentage'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
{/each}
{#if sortedUsers.length === 0}
<tr
><td colspan="4" class="px-3 py-2 text-center text-gray-400"
>{$i18n.t('No data')}</td
></tr
>
{/if}
</tbody>
</table>
</thead>
<tbody>
{#each sortedModels as model, idx (model.model_id)}
<tr
class="dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
on:click={() => {
selectedModel = { id: model.model_id, name: model.name };
showModelModal = true;
}}
>
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
e.target.src = '/favicon.png';
}}
/>
<span class="truncate max-w-[9.375rem]">{model.name}</span>
</div>
</td>
<td class="px-3 py-1 text-right">{model.count.toLocaleString()}</td>
<td class="px-3 py-1 text-right">{(model.unique_users ?? 0).toLocaleString()}</td>
<td class="px-3 py-1 text-right">{(model.unique_chats ?? 0).toLocaleString()}</td>
{#each tokenColumns as column}
<td class="px-3 py-1 text-right"
>{formatNumber(tokenStats[model.model_id]?.[column.key] ?? 0)}</td
>
{/each}
<td class="px-3 py-1 text-right text-gray-400">
{totalModelMessages > 0
? ((model.count / totalModelMessages) * 100).toFixed(1)
: 0}%
</td>
</tr>
{/each}
{#if sortedModels.length === 0}
<tr
><td colspan="9" class="px-3 py-2 text-center text-gray-400"
>{$i18n.t('No data')}</td
></tr
>
{/if}
</tbody>
</table>
</div>
</div>
</div>
{:else}
<!-- User Activity Table -->
<div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th scope="col" class="px-2.5 py-2 w-8">#</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => toggleUserSort('name')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('User')}
{#if userOrderBy === 'name'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleUserSort('count')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Messages')}
{#if userOrderBy === 'count'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
{#each tokenColumns as column}
<th
scope="col"
title={`${column.label} ${$i18n.t('tokens')}`}
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleUserSort(column.key)}
>
<div class="flex gap-1.5 items-center justify-end">
{column.label}
{#if userOrderBy === column.key}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sortedUsers as user, idx (user.user_id)}
<tr class="dark:border-gray-850 text-xs">
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
alt={user.name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/user.png';
}}
/>
<span class="truncate max-w-[9.375rem]"
>{user.name || user.email || user.user_id.substring(0, 8)}</span
>
</div>
</td>
<td class="px-3 py-1 text-right">{user.count.toLocaleString()}</td>
{#each tokenColumns as column}
<td class="px-3 py-1 text-right">{formatNumber(user[column.key] ?? 0)}</td>
{/each}
</tr>
{/each}
{#if sortedUsers.length === 0}
<tr
><td colspan="6" class="px-3 py-2 text-center text-gray-400"
>{$i18n.t('No data')}</td
></tr
>
{/if}
</tbody>
</table>
</div>
</div>
{/if}
</div>
<div class="text-gray-500 text-xs mt-1.5 text-right">

View file

@ -46,8 +46,10 @@
MODEL: 'gpt-realtime-2.1-mini',
VOICE: 'marin',
TRANSCRIPTION_MODEL: 'gpt-transcribe',
INPUT_PARAMS: null,
REALTIME_CALL_PROMPT_TEMPLATE: ''
};
let REALTIME_INPUT_PARAMS = '';
// Audio
let TTS_OPENAI_API_BASE_URL = '';
@ -151,9 +153,23 @@
const updateConfigHandler = async () => {
let openaiParams = {};
let realtimeInputParams: Record<string, unknown> | null = null;
try {
openaiParams = TTS_OPENAI_PARAMS ? JSON.parse(TTS_OPENAI_PARAMS) : {};
TTS_OPENAI_PARAMS = JSON.stringify(openaiParams, null, 2);
if (REALTIME_INPUT_PARAMS.trim()) {
realtimeInputParams = JSON.parse(REALTIME_INPUT_PARAMS);
if (
!realtimeInputParams ||
typeof realtimeInputParams !== 'object' ||
Array.isArray(realtimeInputParams)
) {
throw new Error('Input parameters must be a JSON object');
}
}
REALTIME_INPUT_PARAMS = realtimeInputParams
? JSON.stringify(realtimeInputParams, null, 2)
: '';
} catch {
toast.error($i18n.t('Invalid JSON format for Parameters'));
return;
@ -162,6 +178,7 @@
const res = await updateAudioConfig(localStorage.token, {
realtime: {
...realtime,
INPUT_PARAMS: realtimeInputParams,
REALTIME_CALL_PROMPT_TEMPLATE: (realtime.REALTIME_CALL_PROMPT_TEMPLATE ?? '').trim() || null
},
tts: {
@ -240,6 +257,9 @@
...res.realtime,
REALTIME_CALL_PROMPT_TEMPLATE: res.realtime?.REALTIME_CALL_PROMPT_TEMPLATE ?? ''
};
REALTIME_INPUT_PARAMS = res.realtime?.INPUT_PARAMS
? JSON.stringify(res.realtime.INPUT_PARAMS, null, 2)
: '';
STT_OPENAI_API_BASE_URL = res.stt.OPENAI_API_BASE_URL;
STT_OPENAI_API_KEY = res.stt.OPENAI_API_KEY;
STT_OPENAI_API_REQUEST_FORMAT = res.stt.OPENAI_API_REQUEST_FORMAT || 'multipart';
@ -364,6 +384,16 @@
<AdminSettingField label={$i18n.t('Input Transcription Model')}>
<input class={inputClass} bind:value={realtime.TRANSCRIPTION_MODEL} />
</AdminSettingField>
<AdminSettingField
label={$i18n.t('settings.audio.realtime.inputParams')}
description={$i18n.t('settings.audio.realtime.inputParamsHelp')}
>
<Textarea
className={textareaClass}
bind:value={REALTIME_INPUT_PARAMS}
placeholder={$i18n.t('Enter additional parameters in JSON format')}
/>
</AdminSettingField>
<AdminSettingField label={$i18n.t('Prompt Template')}>
<Textarea
className={textareaClass}

View file

@ -3108,6 +3108,8 @@
"settings.audio.realtime.entra": "Microsoft Entra ID",
"settings.audio.realtime.entraHelp": "Uses DefaultAzureCredential on the backend server, not the signed-in browser user. Configure a managed identity or Azure credentials on the server.",
"settings.audio.realtime.openai": "OpenAI / compatible",
"settings.audio.realtime.inputParams": "Input Parameters",
"settings.audio.realtime.inputParamsHelp": "Additional parameters for realtime audio input in JSON format.",
"settings.personal.about.checkForUpdates.label": "Check for updates",
"settings.personal.about.sections.community.title": "Community",
"settings.personal.about.sections.ollamaVersion.title": "Ollama Version",