mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-17 04:34:13 +00:00
refac
This commit is contained in:
parent
bcf540a989
commit
00f12c0e47
11 changed files with 406 additions and 288 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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',),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue