This commit is contained in:
Timothy Jaeryang Baek 2026-10-12 23:59:19 +04:00
parent 0b1e67c38e
commit d823ebd96d
9 changed files with 78 additions and 10 deletions

View file

@ -1,12 +1,14 @@
"""External apps. Credentials never leave the internal model."""
import base64
import re
import time
from typing import Literal
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrants
from open_webui.models.channels import ChannelMember
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, SecretStr, field_validator
from sqlalchemy import JSON, BigInteger, Boolean, Column, Text, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -54,9 +56,26 @@ class Manifest(StrictModel):
id: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=100)
description: str = Field(default='', max_length=2000)
profile_image_url: str | None = Field(default=None, max_length=196608)
public_key: str = Field(max_length=2000)
requested_scopes: list[Scope]
@field_validator('profile_image_url')
@classmethod
def validate_profile_image_url(cls, value):
if value is not None:
if value.startswith('data:'):
header, encoded = value.split(',', 1)
if not re.fullmatch(r'data:image/(png|jpeg|gif|webp);base64', header):
raise ValueError('Expected a base64 PNG, JPEG, GIF or WebP data URL')
if not base64.b64decode(encoded, validate=True):
raise ValueError('Profile image data must not be empty')
else:
url = HttpUrl(value)
if len(value) > 2048 or url.username is not None or url.password is not None:
raise ValueError('Profile image URL must not contain credentials or exceed 2048 characters')
return value
@field_validator('public_key')
@classmethod
def validate_public_key(cls, value):

View file

@ -295,7 +295,12 @@ class MessageTable:
if message.app_id:
from open_webui.models.apps import App
app = await db.get(App, message.app_id)
return {'id': message.app_id, 'name': app.name if app else 'Removed App', 'role': 'app'}
return {
'id': message.app_id,
'name': app.name if app else 'Removed App',
'role': 'app',
'profile_image_url': app.data.get('manifest', {}).get('profile_image_url') if app else None,
}
webhook_info = message.meta.get('webhook') if message.meta else None
if webhook_info and webhook_info.get('id'):
webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db)

View file

@ -10,6 +10,7 @@ export type App = {
url: string;
manifest: {
description: string;
profile_image_url?: string | null;
};
granted_scopes: string[];
};

View file

@ -1,4 +1,5 @@
<script lang="ts">
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { getApps, updateAppById, type App } from '$lib/apis/apps';
@ -73,7 +74,13 @@
? ''
: 'opacity-50'}"
>
<Grid className="size-4 shrink-0" strokeWidth="1.5" />
{#if app.data.manifest.profile_image_url}
<ProfileImage
src={app.data.manifest.profile_image_url}
allowExternal
className="size-4 shrink-0 rounded!"
/>
{:else}<Grid className="size-4 shrink-0" strokeWidth="1.5" />{/if}
<span class="truncate"
>{app.name}
<span class="text-gray-400 dark:text-gray-600">{app.slug}</span></span

View file

@ -1,4 +1,5 @@
<script lang="ts">
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, config, user as _user, showSidebar } from '$lib/stores';
import { goto } from '$app/navigation';
@ -229,7 +230,13 @@
<div
class="size-6 rounded-lg bg-gray-100 dark:bg-gray-850 flex items-center justify-center shrink-0"
>
<Grid className="size-3.5" />
{#if app.data.manifest.profile_image_url}
<ProfileImage
src={app.data.manifest.profile_image_url}
allowExternal
className="size-6 rounded-lg!"
/>
{:else}<Grid className="size-3.5" />{/if}
</div>
<span class="truncate">{app.name}</span>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import { getAbortSignal, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
@ -29,7 +30,7 @@
let _models = [];
let _users = [];
let _channels = [];
let _apps: { type: string; id: string; label: string }[] = [];
let _apps: { type: string; id: string; label: string; profile_image_url?: string | null }[] = [];
type UserSuggestion = { id: string; name: string };
@ -141,7 +142,12 @@
.then(([apps, joined]) => {
_apps = apps
.filter((a) => a.is_active && joined.some((member) => member.id === a.id))
.map((a) => ({ type: 'app', id: a.id, label: a.name }));
.map((a) => ({
type: 'app',
id: a.id,
label: a.name,
profile_image_url: a.data.manifest.profile_image_url
}));
})
.catch(() => {});
}
@ -240,7 +246,13 @@
{/if}
</div>
{:else if item.type === 'app'}
<Grid className="size-4 mr-2" />
{#if item.profile_image_url}
<ProfileImage
src={item.profile_image_url}
allowExternal
className="size-5 mr-2 rounded-lg!"
/>
{:else}<Grid className="size-4 mr-2" />{/if}
{:else if item.type === 'model'}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${item.id}&lang=${$i18n.language}`}

View file

@ -400,7 +400,16 @@
e.currentTarget.src = '/favicon.png';
}}
/>
{:else if message.app_id}<span class="flex size-8 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800 text-xs" aria-label="App">App</span>
{:else if message.app_id}
{#if message.user?.profile_image_url}
<ProfileImage
src={message.user.profile_image_url}
allowExternal
className="size-6 mt-0.5 rounded-lg!"
/>
{:else}
<span class="flex size-6 mt-0.5 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800 text-xs" aria-label="App">App</span>
{/if}
{:else if message.user?.role === 'webhook'}
<ProfileImage
src={`${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}

View file

@ -9,11 +9,12 @@
const FALLBACK_SRC = `${WEBUI_BASE_URL}/static/favicon.png`;
export let src = FALLBACK_SRC;
export let allowExternal = false;
</script>
<img
aria-hidden="true"
src={safeImageUrl(src)}
src={safeImageUrl(src, allowExternal)}
class=" {className} object-cover rounded-2xl"
alt="profile"
draggable="false"

View file

@ -1,4 +1,5 @@
<script lang="ts">
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import { toast } from 'svelte-sonner';
import { getContext, onMount, onDestroy } from 'svelte';
@ -357,7 +358,13 @@
<div
class="size-6 rounded-lg bg-gray-100 dark:bg-gray-850 flex items-center justify-center shrink-0"
>
<Grid className="size-3.5" />
{#if app.data.manifest.profile_image_url}
<ProfileImage
src={app.data.manifest.profile_image_url}
allowExternal
className="size-6 rounded-lg!"
/>
{:else}<Grid className="size-3.5" />{/if}
</div>
<span class="truncate">{app.name}</span>
</div>