mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-23 05:33:48 +00:00
Merge upstream dev into perf/access-check-plumbing
Resolves the conflict with the unregistered base model fix by passing both user_role and user_group_ids to has_base_model_access, keeping the admin-only fallback for unregistered base models while still sharing the single group membership fetch across the whole chain.
This commit is contained in:
commit
ee145a651f
180 changed files with 5422 additions and 1375 deletions
43
.github/workflows/issue-label.yaml
vendored
Normal file
43
.github/workflows/issue-label.yaml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: Issue Labeler
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label-bug-reports:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add "bug" label to unlabeled bug reports
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
|
||||
// Web-form submissions already carry the label from the issue template
|
||||
if (issue.labels.some((label) => label.name === 'bug')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = issue.title ?? '';
|
||||
const body = issue.body ?? '';
|
||||
|
||||
// Freeform bug reports: "issue: ...", "bug: ...", "fix: ...", "[Bug] ...", "issue/UX: ..."
|
||||
const bugLikeTitle = /^\s*\[?(bug|issue|fix)\]?\s*[:/\-]/i.test(title);
|
||||
|
||||
// API/CLI-created issues that reproduce the bug report form structure.
|
||||
// Only headings distinctive to the bug form (both are required fields there) —
|
||||
// generic headings like "Expected Behavior" also appear in freeform feature requests.
|
||||
const bugFormBody = /###\s*(Installation Method|Open WebUI Version)/i.test(body);
|
||||
|
||||
if (bugLikeTitle || bugFormBody) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['bug']
|
||||
});
|
||||
}
|
||||
|
|
@ -2143,6 +2143,10 @@ CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THR
|
|||
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
|
||||
CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None
|
||||
|
||||
CONTEXT_COMPACTION_RETENTION_PERCENTAGE = min(
|
||||
50, max(10, int(os.getenv('CONTEXT_COMPACTION_RETENTION_PERCENTAGE', '40')))
|
||||
)
|
||||
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '')
|
||||
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '')
|
||||
|
|
@ -2567,6 +2571,24 @@ if _oauth_authorize_params:
|
|||
log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
|
||||
|
||||
|
||||
def oauth_client_kwargs(scope: str, **kwargs):
|
||||
client_kwargs = {
|
||||
'scope': scope,
|
||||
**kwargs,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
}
|
||||
|
||||
if OAUTH_CODE_CHALLENGE_METHOD == 'S256':
|
||||
client_kwargs['code_challenge_method'] = 'S256'
|
||||
elif OAUTH_CODE_CHALLENGE_METHOD:
|
||||
raise Exception(
|
||||
'Code challenge methods other than "%s" not supported. Given: "%s"'
|
||||
% ('S256', OAUTH_CODE_CHALLENGE_METHOD)
|
||||
)
|
||||
|
||||
return client_kwargs
|
||||
|
||||
|
||||
def load_oauth_providers():
|
||||
OAUTH_PROVIDERS.clear()
|
||||
if GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET:
|
||||
|
|
@ -2577,10 +2599,7 @@ def load_oauth_providers():
|
|||
client_id=GOOGLE_CLIENT_ID,
|
||||
client_secret=GOOGLE_CLIENT_SECRET,
|
||||
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
||||
client_kwargs={
|
||||
'scope': GOOGLE_OAUTH_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(GOOGLE_OAUTH_SCOPE),
|
||||
redirect_uri=GOOGLE_REDIRECT_URI,
|
||||
**({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}),
|
||||
)
|
||||
|
|
@ -2598,10 +2617,7 @@ def load_oauth_providers():
|
|||
client_id=MICROSOFT_CLIENT_ID,
|
||||
client_secret=MICROSOFT_CLIENT_SECRET,
|
||||
server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL}/{MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID}',
|
||||
client_kwargs={
|
||||
'scope': MICROSOFT_OAUTH_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(MICROSOFT_OAUTH_SCOPE),
|
||||
redirect_uri=MICROSOFT_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
|
|
@ -2622,10 +2638,7 @@ def load_oauth_providers():
|
|||
authorize_url='https://github.com/login/oauth/authorize',
|
||||
api_base_url='https://api.github.com',
|
||||
userinfo_endpoint='https://api.github.com/user',
|
||||
client_kwargs={
|
||||
'scope': GITHUB_CLIENT_SCOPE,
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
},
|
||||
client_kwargs=oauth_client_kwargs(GITHUB_CLIENT_SCOPE),
|
||||
redirect_uri=GITHUB_CLIENT_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
|
|
@ -2638,30 +2651,19 @@ def load_oauth_providers():
|
|||
if OAUTH_CLIENT_ID and (OAUTH_CLIENT_SECRET or OAUTH_CODE_CHALLENGE_METHOD) and OPENID_PROVIDER_URL:
|
||||
|
||||
def oidc_oauth_register(oauth: OAuth):
|
||||
client_kwargs = {
|
||||
'scope': OAUTH_SCOPES,
|
||||
**(
|
||||
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
|
||||
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
|
||||
else {}
|
||||
),
|
||||
**({'timeout': int(OAUTH_TIMEOUT)} if OAUTH_TIMEOUT else {}),
|
||||
}
|
||||
|
||||
if OAUTH_CODE_CHALLENGE_METHOD and OAUTH_CODE_CHALLENGE_METHOD == 'S256':
|
||||
client_kwargs['code_challenge_method'] = 'S256'
|
||||
elif OAUTH_CODE_CHALLENGE_METHOD:
|
||||
raise Exception(
|
||||
'Code challenge methods other than "%s" not supported. Given: "%s"'
|
||||
% ('S256', OAUTH_CODE_CHALLENGE_METHOD)
|
||||
)
|
||||
|
||||
client = oauth.register(
|
||||
name='oidc',
|
||||
client_id=OAUTH_CLIENT_ID,
|
||||
client_secret=OAUTH_CLIENT_SECRET,
|
||||
server_metadata_url=OPENID_PROVIDER_URL,
|
||||
client_kwargs=client_kwargs,
|
||||
client_kwargs=oauth_client_kwargs(
|
||||
OAUTH_SCOPES,
|
||||
**(
|
||||
{'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD}
|
||||
if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD
|
||||
else {}
|
||||
),
|
||||
),
|
||||
redirect_uri=OPENID_REDIRECT_URI,
|
||||
)
|
||||
return client
|
||||
|
|
@ -3056,6 +3058,7 @@ DEFAULT_CONFIG = {
|
|||
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
|
||||
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
|
||||
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
|
||||
'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE,
|
||||
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
|
||||
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
|
|
|
|||
|
|
@ -594,6 +594,15 @@ try:
|
|||
except (ValueError, TypeError):
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
|
||||
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE = os.getenv('AIOHTTP_FILE_STREAM_CHUNK_SIZE', str(1024 * 1024))
|
||||
try:
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE = int(AIOHTTP_FILE_STREAM_CHUNK_SIZE)
|
||||
except Exception:
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
if AIOHTTP_FILE_STREAM_CHUNK_SIZE <= 0:
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
# SSL verification for tool server connections specifically.
|
||||
# Accepts "True", "False", or a path to a CA bundle file.
|
||||
|
|
@ -1093,15 +1102,19 @@ except ValueError:
|
|||
MAX_BODY_LOG_SIZE = 2048
|
||||
|
||||
# Comma separated list for urls to exclude from audit
|
||||
AUDIT_EXCLUDED_PATHS = os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
|
||||
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
|
||||
AUDIT_EXCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_EXCLUDED_PATHS]
|
||||
AUDIT_EXCLUDED_PATHS = [
|
||||
path
|
||||
for path in (
|
||||
path.strip().lstrip('/') for path in os.getenv('AUDIT_EXCLUDED_PATHS', '/chats,/chat,/folders').split(',')
|
||||
)
|
||||
if path
|
||||
]
|
||||
|
||||
# Comma separated list of urls to include in audit (whitelist mode)
|
||||
# When set, only these paths are audited and AUDIT_EXCLUDED_PATHS is ignored
|
||||
AUDIT_INCLUDED_PATHS = os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')
|
||||
AUDIT_INCLUDED_PATHS = [path.strip() for path in AUDIT_INCLUDED_PATHS]
|
||||
AUDIT_INCLUDED_PATHS = [path.lstrip('/') for path in AUDIT_INCLUDED_PATHS if path]
|
||||
AUDIT_INCLUDED_PATHS = [
|
||||
path for path in (path.strip().lstrip('/') for path in os.getenv('AUDIT_INCLUDED_PATHS', '').split(',')) if path
|
||||
]
|
||||
|
||||
# When enabled, GET requests are also audited (disabled by default to avoid log noise)
|
||||
ENABLE_AUDIT_GET_REQUESTS = os.getenv('ENABLE_AUDIT_GET_REQUESTS', 'False').lower() == 'true'
|
||||
|
|
|
|||
|
|
@ -370,6 +370,8 @@ if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
|
|||
|
||||
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
||||
# Generous default — async coroutines + no session sharing = high connection demand.
|
||||
# No pool_pre_ping: a local SQLite file cannot drop connections, and the
|
||||
# ping costs a worker-thread hop plus a SELECT 1 on every checkout.
|
||||
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
|
||||
async_engine = create_async_engine(
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL,
|
||||
|
|
@ -377,7 +379,6 @@ if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
|||
pool_size=_sqlite_pool_size,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
@event.listens_for(async_engine.sync_engine, 'connect')
|
||||
|
|
|
|||
|
|
@ -220,6 +220,9 @@ from open_webui.utils.chat import (
|
|||
from open_webui.utils.chat import (
|
||||
generate_chat_completion as chat_completion_handler,
|
||||
)
|
||||
from open_webui.utils.chat_variables import (
|
||||
normalize_chat_variables,
|
||||
)
|
||||
from open_webui.utils.embeddings import generate_embeddings
|
||||
from open_webui.utils.logger import start_logger
|
||||
from open_webui.utils.middleware import (
|
||||
|
|
@ -717,6 +720,25 @@ app.state.speech_speaker_embeddings_dataset = None
|
|||
app.state.MODELS = MODELS
|
||||
|
||||
# Add the middleware to the app
|
||||
try:
|
||||
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
|
||||
except ValueError as e:
|
||||
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
|
||||
audit_level = AuditLevel.NONE
|
||||
|
||||
# Added before CompressMiddleware so audit sits inside compression and
|
||||
# captures response bodies before they are compressed (last added runs
|
||||
# outermost).
|
||||
if audit_level != AuditLevel.NONE:
|
||||
app.add_middleware(
|
||||
AuditLoggingMiddleware,
|
||||
audit_level=audit_level,
|
||||
excluded_paths=AUDIT_EXCLUDED_PATHS,
|
||||
included_paths=AUDIT_INCLUDED_PATHS,
|
||||
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
|
||||
max_body_size=MAX_BODY_LOG_SIZE,
|
||||
)
|
||||
|
||||
if ENABLE_COMPRESSION_MIDDLEWARE:
|
||||
app.add_middleware(CompressMiddleware)
|
||||
|
||||
|
|
@ -795,21 +817,6 @@ if ENABLE_SCIM:
|
|||
app.include_router(scim.router, prefix='/api/v1/scim/v2', tags=['scim'])
|
||||
|
||||
|
||||
try:
|
||||
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
|
||||
except ValueError as e:
|
||||
logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}')
|
||||
audit_level = AuditLevel.NONE
|
||||
|
||||
if audit_level != AuditLevel.NONE:
|
||||
app.add_middleware(
|
||||
AuditLoggingMiddleware,
|
||||
audit_level=audit_level,
|
||||
excluded_paths=AUDIT_EXCLUDED_PATHS,
|
||||
included_paths=AUDIT_INCLUDED_PATHS,
|
||||
audit_get_requests=ENABLE_AUDIT_GET_REQUESTS,
|
||||
max_body_size=MAX_BODY_LOG_SIZE,
|
||||
)
|
||||
##################################
|
||||
#
|
||||
# Chat Endpoints
|
||||
|
|
@ -822,12 +829,18 @@ if audit_level != AuditLevel.NONE:
|
|||
async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)):
|
||||
all_models = await get_all_models(request, refresh=refresh, user=user)
|
||||
|
||||
models = []
|
||||
for model in all_models:
|
||||
# Filter out filter pipelines
|
||||
if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter':
|
||||
continue
|
||||
# Filter out filter pipelines
|
||||
models = [model for model in all_models if not ('pipeline' in model and model['pipeline'].get('type', None) == 'filter')]
|
||||
|
||||
# Chat requests resolve models by ID from request.app.state.MODELS, where
|
||||
# duplicate IDs collapse to the last model. Return the same effective list.
|
||||
models = list({model['id']: model for model in models}.values())
|
||||
|
||||
# Access-filter first so the per-model payload work below only runs for
|
||||
# models the caller can actually see.
|
||||
models = await get_filtered_models(models, user)
|
||||
|
||||
for model in models:
|
||||
# Remove profile image URL to reduce payload size
|
||||
if model.get('info', {}).get('meta', {}).get('profile_image_url'):
|
||||
model['info']['meta'].pop('profile_image_url', None)
|
||||
|
|
@ -841,13 +854,6 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
|
|||
except Exception as e:
|
||||
log.debug(f'Error processing model tags: {e}')
|
||||
model['tags'] = []
|
||||
pass
|
||||
|
||||
models.append(model)
|
||||
|
||||
# Chat requests resolve models by ID from request.app.state.MODELS, where
|
||||
# duplicate IDs collapse to the last model. Return the same effective list.
|
||||
models = list({model['id']: model for model in models}.values())
|
||||
|
||||
model_order_list = await Config.get('ui.model_order_list')
|
||||
if model_order_list:
|
||||
|
|
@ -860,11 +866,10 @@ async def get_models(request: Request, refresh: bool = False, user=Depends(get_v
|
|||
)
|
||||
)
|
||||
|
||||
models = await get_filtered_models(models, user)
|
||||
|
||||
log.debug(
|
||||
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
|
||||
)
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug(
|
||||
f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}'
|
||||
)
|
||||
return {'data': models}
|
||||
|
||||
|
||||
|
|
@ -1133,6 +1138,13 @@ async def chat_completion(
|
|||
message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}]
|
||||
|
||||
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
|
||||
chat_id = form_data.get('chat_id') or ''
|
||||
chat_variables = form_data.pop('chat_variables', None)
|
||||
if chat_variables is None:
|
||||
existing_chat = await Chats.get_chat_by_id(chat_id) if chat_id else None
|
||||
chat_variables = existing_chat.variables if existing_chat else {}
|
||||
|
||||
chat_variables = normalize_chat_variables(chat_variables)
|
||||
|
||||
# Drop tool_servers if caller lacks features.direct_tool_servers —
|
||||
# mirrors the storage-side strip in user/settings/update.
|
||||
|
|
@ -1164,6 +1176,7 @@ async def chat_completion(
|
|||
'files': form_data.get('files', None),
|
||||
'features': form_data.get('features', {}),
|
||||
'variables': form_data.get('variables', {}),
|
||||
'chat_variables': chat_variables,
|
||||
'model': model,
|
||||
'direct': model_item.get('direct', False),
|
||||
'params': {
|
||||
|
|
@ -1278,6 +1291,7 @@ async def chat_completion(
|
|||
'tags': [],
|
||||
'timestamp': int(time.time() * 1000),
|
||||
},
|
||||
variables=chat_variables,
|
||||
folder_id=metadata.get('folder_id'),
|
||||
),
|
||||
)
|
||||
|
|
@ -1383,6 +1397,8 @@ async def chat_completion(
|
|||
updated['models'] = selected_chat_models
|
||||
await Chats.update_chat_by_id(chat_id, updated)
|
||||
|
||||
await Chats.update_chat_variables_by_id(chat_id, chat_variables)
|
||||
|
||||
# Save user message to DB
|
||||
if user_message and user_message.get('id'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
"""Add memory (id, user_id) covering index
|
||||
|
||||
Revision ID: 55f1302ac17c
|
||||
Revises: b0018471bbbe
|
||||
Create Date: 2026-07-24 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = '55f1302ac17c'
|
||||
down_revision: Union[str, None] = 'b0018471bbbe'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
indexes = {index['name'] for index in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_id_user_id' not in indexes:
|
||||
op.create_index('ix_memory_id_user_id', 'memory', ['id', 'user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
indexes = {index['name'] for index in inspector.get_indexes('memory')}
|
||||
|
||||
if 'ix_memory_id_user_id' in indexes:
|
||||
op.drop_index('ix_memory_id_user_id', table_name='memory')
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""add user variables
|
||||
|
||||
Revision ID: b0018471bbbe
|
||||
Revises: c49178636c78
|
||||
Create Date: 2026-07-24 01:21:46.457057
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b0018471bbbe'
|
||||
down_revision: Union[str, None] = 'c49178636c78'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('user')]
|
||||
|
||||
if 'variables' not in columns:
|
||||
op.add_column('user', sa.Column('variables', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'variables')
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""add chat variables
|
||||
|
||||
Revision ID: c49178636c78
|
||||
Revises: 9a1b2c3d4e5f
|
||||
Create Date: 2026-07-23 23:33:45.497453
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c49178636c78'
|
||||
down_revision: Union[str, None] = '9a1b2c3d4e5f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
|
||||
if 'variables' not in columns:
|
||||
op.add_column('chat', sa.Column('variables', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat', 'variables')
|
||||
|
|
@ -316,7 +316,6 @@ class AccessGrantsTable:
|
|||
)
|
||||
db.add(grant)
|
||||
await db.commit()
|
||||
await db.refresh(grant)
|
||||
return AccessGrantModel.model_validate(grant)
|
||||
|
||||
async def revoke_access(
|
||||
|
|
|
|||
|
|
@ -133,9 +133,8 @@ class AuthsTable:
|
|||
oauth=oauth,
|
||||
db=session,
|
||||
)
|
||||
# persist both records and reload generated defaults
|
||||
# persist both records
|
||||
await session.commit()
|
||||
await session.refresh(credential)
|
||||
return created_user if credential and created_user else None
|
||||
|
||||
async def authenticate_user(
|
||||
|
|
|
|||
|
|
@ -139,7 +139,6 @@ class AutomationTable:
|
|||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int:
|
||||
|
|
@ -224,7 +223,6 @@ class AutomationTable:
|
|||
row.next_run_at = next_run_at
|
||||
row.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def toggle(
|
||||
|
|
@ -241,7 +239,6 @@ class AutomationTable:
|
|||
row.next_run_at = next_run_at if row.is_active else None
|
||||
row.updated_at = int(time.time_ns())
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationModel.model_validate(row)
|
||||
|
||||
async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool:
|
||||
|
|
@ -324,7 +321,6 @@ class AutomationRunTable:
|
|||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return AutomationRunModel.model_validate(row)
|
||||
|
||||
async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]:
|
||||
|
|
|
|||
|
|
@ -500,9 +500,12 @@ class CalendarEventTable:
|
|||
# Filter to requested calendars only
|
||||
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
|
||||
|
||||
# Also get event IDs where user is an attendee
|
||||
# Also get event IDs where the user is an attendee, excluding invites they declined
|
||||
attendee_event_ids_result = await db.execute(
|
||||
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
|
||||
select(CalendarEventAttendee.event_id).filter(
|
||||
CalendarEventAttendee.user_id == user_id,
|
||||
CalendarEventAttendee.status != 'declined',
|
||||
)
|
||||
)
|
||||
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
|
||||
|
||||
|
|
@ -764,22 +767,34 @@ class CalendarEventAttendeeTable:
|
|||
async def set_attendees(
|
||||
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
|
||||
) -> list[CalendarEventAttendeeModel]:
|
||||
"""Replace all attendees for an event.
|
||||
"""Replace all attendees for an event ({user_id, meta?} per dict).
|
||||
|
||||
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
|
||||
RSVP status is the attendee's alone to set (via update_rsvp): an existing
|
||||
attendee keeps their status, a newly added one starts 'pending'. A
|
||||
caller-supplied status is ignored so an organiser cannot set it for others.
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
existing_status = {
|
||||
row.user_id: row.status
|
||||
for row in (
|
||||
await db.execute(
|
||||
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
|
||||
# Remove existing
|
||||
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
|
||||
|
||||
now = int(time.time_ns())
|
||||
models = []
|
||||
for att in attendees:
|
||||
user_id = att['user_id']
|
||||
row = CalendarEventAttendee(
|
||||
id=str(uuid4()),
|
||||
event_id=event_id,
|
||||
user_id=att['user_id'],
|
||||
status=att.get('status', 'pending'),
|
||||
user_id=user_id,
|
||||
status=existing_status.get(user_id, 'pending'),
|
||||
meta=att.get('meta'),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
|
|
|
|||
|
|
@ -869,7 +869,6 @@ class ChannelTable:
|
|||
result = ChannelFile(**channel_file.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return ChannelFileModel.model_validate(result)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -259,7 +259,6 @@ class ChatMessageTable:
|
|||
existing.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
|
||||
existing.updated_at = now
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return ChatMessageModel.model_validate(existing)
|
||||
else:
|
||||
# Insert new
|
||||
|
|
@ -288,7 +287,6 @@ class ChatMessageTable:
|
|||
)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return ChatMessageModel.model_validate(message)
|
||||
|
||||
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ from open_webui.models.automations import AutomationRun
|
|||
from open_webui.models.chat_messages import ChatMessage, ChatMessages
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.tags import Tag, TagModel, Tags
|
||||
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from open_webui.utils.misc import get_output_text, sanitize_data_for_db, sanitize_text_for_db
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
|
|
@ -58,6 +58,7 @@ class Chat(Base): # database table mapping for chat entity
|
|||
pinned = Column(Boolean, default=False, nullable=True)
|
||||
|
||||
meta = Column(JSON, server_default='{}')
|
||||
variables = Column(JSON, nullable=True)
|
||||
folder_id = Column(Text, nullable=True)
|
||||
|
||||
tasks = Column(JSON, nullable=True)
|
||||
|
|
@ -95,6 +96,7 @@ class ChatModel(BaseModel):
|
|||
pinned: bool | None = False
|
||||
|
||||
meta: dict = {}
|
||||
variables: dict = {}
|
||||
folder_id: str | None = None
|
||||
|
||||
tasks: list | None = None
|
||||
|
|
@ -103,6 +105,11 @@ class ChatModel(BaseModel):
|
|||
|
||||
last_read_at: int | None = None
|
||||
|
||||
@field_validator('variables', mode='before')
|
||||
@classmethod
|
||||
def normalize_variables(cls, value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class ChatFile(Base):
|
||||
__tablename__ = 'chat_file'
|
||||
|
|
@ -141,6 +148,7 @@ class ChatFileModel(BaseModel):
|
|||
|
||||
class ChatForm(BaseModel):
|
||||
chat: dict
|
||||
variables: dict | None = None
|
||||
folder_id: str | None = None
|
||||
|
||||
|
||||
|
|
@ -176,6 +184,7 @@ class ChatResponse(BaseModel):
|
|||
archived: bool
|
||||
pinned: bool | None = False
|
||||
meta: dict = {}
|
||||
variables: dict = {}
|
||||
folder_id: str | None = None
|
||||
|
||||
tasks: list | None = None
|
||||
|
|
@ -183,6 +192,11 @@ class ChatResponse(BaseModel):
|
|||
current_message_id: str | None = None
|
||||
context_usage: dict | None = None
|
||||
|
||||
@field_validator('variables', mode='before')
|
||||
@classmethod
|
||||
def normalize_variables(cls, value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class ChatTitleIdResponse(BaseModel):
|
||||
id: str
|
||||
|
|
@ -394,6 +408,7 @@ class ChatTable:
|
|||
'chat': self._clean_null_bytes(form_data.chat),
|
||||
'folder_id': form_data.folder_id,
|
||||
'meta': internal_meta or {},
|
||||
'variables': form_data.variables or {},
|
||||
'current_message_id': self.get_current_message_id(form_data.chat),
|
||||
'created_at': int(time.time()),
|
||||
'updated_at': int(time.time()),
|
||||
|
|
@ -404,7 +419,6 @@ class ChatTable:
|
|||
chat_item = Chat(**chat.model_dump())
|
||||
session.add(chat_item)
|
||||
await session.commit()
|
||||
await session.refresh(chat_item)
|
||||
|
||||
# Dual-write initial messages to chat_message table
|
||||
try:
|
||||
|
|
@ -482,6 +496,7 @@ class ChatTable:
|
|||
'title': self._clean_null_bytes(form_data.chat['title'] if 'title' in form_data.chat else 'New Chat'),
|
||||
'chat': self._clean_null_bytes(form_data.chat),
|
||||
'meta': form_data.meta,
|
||||
'variables': form_data.variables or {},
|
||||
'pinned': form_data.pinned,
|
||||
'folder_id': form_data.folder_id,
|
||||
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
|
||||
|
|
@ -575,6 +590,29 @@ class ChatTable:
|
|||
except Exception:
|
||||
return
|
||||
|
||||
async def update_chat_variables_by_id(
|
||||
self,
|
||||
id: str,
|
||||
variables: dict | None,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
touch: bool = True,
|
||||
) -> ChatModel | None:
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_item = await session.get(Chat, id)
|
||||
if chat_item is None:
|
||||
return None
|
||||
|
||||
chat_item.variables = variables if isinstance(variables, dict) else {}
|
||||
if touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
||||
await session.commit()
|
||||
return ChatModel.model_validate(chat_item)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: AsyncSession | None = None) -> bool:
|
||||
try:
|
||||
async with get_async_db_context(db) as session:
|
||||
|
|
@ -597,7 +635,6 @@ class ChatTable:
|
|||
chat_item.title = clean_title
|
||||
chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title}
|
||||
await session.commit()
|
||||
await session.refresh(chat_item)
|
||||
return ChatModel.model_validate(chat_item)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -615,7 +652,6 @@ class ChatTable:
|
|||
# Single meta update
|
||||
chat.meta = {**chat.meta, 'tags': new_tag_ids}
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
|
||||
# Batch-create any missing tag rows
|
||||
await Tags.ensure_tags_exist(new_tags, user.id, db=session)
|
||||
|
|
@ -804,6 +840,11 @@ class ChatTable:
|
|||
if chat is None:
|
||||
return None
|
||||
|
||||
if not message.get('content'):
|
||||
output_text = get_output_text(message.get('output'))
|
||||
if output_text:
|
||||
message['content'] = output_text
|
||||
|
||||
# Sanitize message content for null characters before upserting
|
||||
if isinstance(message.get('content'), str):
|
||||
message['content'] = sanitize_text_for_db(message['content'])
|
||||
|
|
@ -947,7 +988,6 @@ class ChatTable:
|
|||
# Set share_id on the original chat
|
||||
chat.share_id = shared.id
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat) # return the updated original
|
||||
|
||||
# refresh helper
|
||||
|
|
@ -994,7 +1034,6 @@ class ChatTable:
|
|||
chat = await session.get(Chat, id)
|
||||
chat.share_id = share_id
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -1007,7 +1046,6 @@ class ChatTable:
|
|||
chat.updated_at = int(time.time())
|
||||
chat.last_read_at = int(time.time())
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -1021,7 +1059,6 @@ class ChatTable:
|
|||
chat.updated_at = int(time.time())
|
||||
chat.last_read_at = int(time.time())
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -1327,7 +1364,6 @@ class ChatTable:
|
|||
flag_modified(chat_item, 'chat')
|
||||
if self._sanitize_chat_row(chat_item) or repaired_history:
|
||||
await session.commit()
|
||||
await session.refresh(chat_item)
|
||||
|
||||
return ChatModel.model_validate(chat_item)
|
||||
except Exception:
|
||||
|
|
@ -1369,7 +1405,6 @@ class ChatTable:
|
|||
flag_modified(chat, 'chat')
|
||||
if self._sanitize_chat_row(chat) or repaired_history:
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
|
|
@ -1851,7 +1886,6 @@ class ChatTable:
|
|||
chat.last_read_at = int(time.time())
|
||||
chat.pinned = False
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -1931,7 +1965,6 @@ class ChatTable:
|
|||
'tags': list(set(chat.meta.get('tags', []) + [tag_id])),
|
||||
}
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -2231,7 +2264,6 @@ class ChatTable:
|
|||
return None
|
||||
chat.tasks = tasks
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -248,6 +248,10 @@ class Config(Base):
|
|||
now = int(time.time())
|
||||
new_count = 0
|
||||
for key, value in defaults.items():
|
||||
# Skip keys the DB is not authoritative for (e.g. oauth.* while
|
||||
# ENABLE_OAUTH_PERSISTENT_CONFIG is off), matching the read paths.
|
||||
if not Config.persistent_enabled_for(key):
|
||||
continue
|
||||
if key not in existing_keys:
|
||||
value = _json_value(value)
|
||||
db.add(Config(key=key, value=value, updated_at=now))
|
||||
|
|
|
|||
|
|
@ -165,7 +165,6 @@ class FeedbackTable:
|
|||
result = Feedback(**feedback.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return FeedbackModel.model_validate(result)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -142,7 +142,6 @@ class FilesTable:
|
|||
result = File(**file.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return FileModel.model_validate(result)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class FunctionMeta(BaseModel):
|
|||
|
||||
class FunctionModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
|
|
@ -58,7 +58,7 @@ class FunctionModel(BaseModel):
|
|||
# --- form / schema definitions ---
|
||||
class FunctionWithValvesModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
type: str
|
||||
content: str
|
||||
|
|
@ -79,7 +79,7 @@ class FunctionWithValvesModel(BaseModel):
|
|||
|
||||
class FunctionResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
type: str
|
||||
name: str
|
||||
meta: FunctionMeta
|
||||
|
|
@ -129,7 +129,6 @@ class FunctionsTable:
|
|||
result = Function(**function.model_dump())
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
if result:
|
||||
return FunctionModel.model_validate(result)
|
||||
else:
|
||||
|
|
@ -275,11 +274,17 @@ class FunctionsTable:
|
|||
result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True))
|
||||
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
|
||||
|
||||
async def get_active_function_ids_by_type(
|
||||
self, type: str, db: AsyncSession | None = None
|
||||
) -> list[tuple[str, bool]]:
|
||||
"""Return (id, is_global) for active functions without fetching plugin source."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Function.id, Function.is_global).filter_by(type=type, is_active=True))
|
||||
return [(id, bool(is_global)) for id, is_global in result.all()]
|
||||
|
||||
async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]:
|
||||
"""Return (id, is_global) for active filters without fetching plugin source."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Function.id, Function.is_global).filter_by(type='filter', is_active=True))
|
||||
return [(id, bool(is_global)) for id, is_global in result.all()]
|
||||
return await self.get_active_function_ids_by_type('filter', db=db)
|
||||
|
||||
async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
|
|
@ -320,7 +325,6 @@ class FunctionsTable:
|
|||
function.valves = encrypt_valves(valves)
|
||||
function.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(function)
|
||||
return FunctionModel.model_validate(function)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -340,7 +344,6 @@ class FunctionsTable:
|
|||
|
||||
function.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(function)
|
||||
return FunctionModel.model_validate(function)
|
||||
else:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -479,20 +479,16 @@ class KnowledgeTable:
|
|||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
result = []
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.user_id == user_id:
|
||||
result.append(knowledge_base)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge_base.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
result.append(knowledge_base)
|
||||
return result
|
||||
# One grants query for all non-owned knowledge bases instead of one each
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_ids=[kb.id for kb in knowledge_bases if kb.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [kb for kb in knowledge_bases if kb.user_id == user_id or kb.id in accessible_ids]
|
||||
|
||||
async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
|
||||
try:
|
||||
|
|
@ -675,9 +671,25 @@ class KnowledgeTable:
|
|||
async def get_file_metadatas_by_id(
|
||||
self, knowledge_id: str, db: Optional[AsyncSession] = None
|
||||
) -> list[FileMetadataResponse]:
|
||||
"""Column-only listing: File.data holds each file's full extracted
|
||||
text, which metadata views must never load."""
|
||||
try:
|
||||
files = await self.get_files_by_id(knowledge_id, db=db)
|
||||
return [FileMetadataResponse(**file.model_dump()) for file in files]
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(File.id, File.hash, File.meta, File.created_at, File.updated_at)
|
||||
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
|
||||
.filter(KnowledgeFile.knowledge_id == knowledge_id)
|
||||
)
|
||||
return [
|
||||
FileMetadataResponse(
|
||||
id=row.id,
|
||||
hash=row.hash,
|
||||
meta=row.meta,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
for row in result.all()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import Literal
|
|||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Column, String, Text, delete, select
|
||||
from sqlalchemy import JSON, BigInteger, Column, Index, String, Text, delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ class Memory(Base): # user memory store
|
|||
"""Stores user-created memory entries linked to a vector collection."""
|
||||
|
||||
__tablename__ = 'memory'
|
||||
__table_args__ = (Index('ix_memory_id_user_id', 'id', 'user_id'),)
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
user_id = Column(String, index=True)
|
||||
|
|
@ -70,7 +71,6 @@ class MemoriesTable:
|
|||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return MemoryModel.model_validate(record) if record else None
|
||||
|
||||
async def update_memory_by_id_and_user_id(
|
||||
|
|
@ -101,7 +101,6 @@ class MemoriesTable:
|
|||
memory.updated_at = int(time.time())
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(memory)
|
||||
return MemoryModel.model_validate(memory)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -220,7 +220,6 @@ class ModelsTable:
|
|||
)
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db)
|
||||
|
||||
if result:
|
||||
|
|
@ -303,26 +302,26 @@ class ModelsTable:
|
|||
]
|
||||
|
||||
async def get_models_by_user_id(
|
||||
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = 'write',
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> list[ModelUserResponse]:
|
||||
models = await self.get_models(db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
|
||||
result = []
|
||||
for model in models:
|
||||
if model.user_id == user_id:
|
||||
result.append(model)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_id=model.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
result.append(model)
|
||||
return result
|
||||
# One grants query for all non-owned models instead of one per model
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_ids=[model.id for model in models if model.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [model for model in models if model.user_id == user_id or model.id in accessible_ids]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
|
||||
return AccessGrants.has_permission_filter(
|
||||
|
|
@ -530,7 +529,6 @@ class ModelsTable:
|
|||
model.is_active = not model.is_active
|
||||
model.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(model)
|
||||
|
||||
return await self._to_model_model(model, db=db)
|
||||
except Exception:
|
||||
|
|
@ -562,7 +560,6 @@ class ModelsTable:
|
|||
return None
|
||||
model_obj.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(model_obj)
|
||||
return await self._to_model_model(model_obj, db=db)
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to update the model updated_at by id {id}: {e}')
|
||||
|
|
|
|||
|
|
@ -128,7 +128,6 @@ class OAuthSessionTable:
|
|||
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
|
||||
if result:
|
||||
# Make a copy of the model data before closing session
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ class PromptHistoryTable:
|
|||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(history)
|
||||
return PromptHistoryModel.model_validate(history)
|
||||
|
||||
async def get_history_by_prompt_id(
|
||||
|
|
|
|||
|
|
@ -132,7 +132,6 @@ class PromptsTable:
|
|||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record) # populate generated defaults
|
||||
|
||||
await AccessGrants.set_access_grants(
|
||||
'prompt',
|
||||
|
|
@ -169,7 +168,6 @@ class PromptsTable:
|
|||
if history_entry:
|
||||
record.version_id = history_entry.id
|
||||
await session.commit()
|
||||
await session.refresh(record) # re-read version_id
|
||||
|
||||
return await self._to_prompt_model(record, db=session)
|
||||
except Exception as e:
|
||||
|
|
@ -637,7 +635,6 @@ class PromptsTable:
|
|||
prompt.is_active = not prompt.is_active
|
||||
prompt.updated_at = int(time.time())
|
||||
await session.commit()
|
||||
await session.refresh(prompt)
|
||||
return await self._to_prompt_model(prompt, db=session)
|
||||
return None
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -137,7 +137,6 @@ class SkillsTable:
|
|||
)
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db)
|
||||
if result:
|
||||
return await self._to_skill_model(result, db=db)
|
||||
|
|
@ -326,8 +325,8 @@ class SkillsTable:
|
|||
if access_grants is not None:
|
||||
await AccessGrants.set_access_grants('skill', id, access_grants, db=db)
|
||||
|
||||
skill = await db.get(Skill, id)
|
||||
await db.refresh(skill)
|
||||
# populate_existing: the Core update above bypasses any identity-map copy
|
||||
skill = await db.get(Skill, id, populate_existing=True)
|
||||
return await self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -343,7 +342,6 @@ class SkillsTable:
|
|||
skill.is_active = not skill.is_active
|
||||
skill.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await db.refresh(skill)
|
||||
|
||||
return await self._to_skill_model(skill, db=db)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ class TagTable:
|
|||
record = Tag(id=tag_id, user_id=user_id, name=name)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return TagModel.model_validate(record) if record else None
|
||||
except Exception as e:
|
||||
log.exception('Error inserting tag %r: %s', name, e)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@ class ToolMeta(BaseModel):
|
|||
|
||||
class ToolModel(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
content: str
|
||||
# None when listed with defer_content=True (source skipped for listings)
|
||||
content: str | None = None
|
||||
specs: list[dict]
|
||||
meta: ToolMeta
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
|
@ -65,7 +66,7 @@ class ToolUserModel(ToolModel):
|
|||
|
||||
class ToolResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
user_id: str | None = None # may be null for legacy/malformed records
|
||||
name: str
|
||||
meta: ToolMeta
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
|
@ -131,7 +132,6 @@ class ToolsTable:
|
|||
)
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
await AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db)
|
||||
if result:
|
||||
return await self._to_tool_model(result, db=db)
|
||||
|
|
@ -171,11 +171,18 @@ class ToolsTable:
|
|||
|
||||
async def get_tools(self, defer_content: bool = False, db: AsyncSession | None = None) -> list[ToolUserModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(Tool).order_by(Tool.updated_at.desc())
|
||||
if defer_content:
|
||||
stmt = stmt
|
||||
result = await db.execute(stmt)
|
||||
all_tools = result.scalars().all()
|
||||
# Skip Tool.content (plugin source, potentially large) via a
|
||||
# column select; Row attributes satisfy from_attributes.
|
||||
result = await db.execute(
|
||||
select(
|
||||
Tool.id, Tool.user_id, Tool.name, Tool.specs, Tool.meta, Tool.updated_at, Tool.created_at
|
||||
).order_by(Tool.updated_at.desc())
|
||||
)
|
||||
all_tools = result.all()
|
||||
else:
|
||||
result = await db.execute(select(Tool).order_by(Tool.updated_at.desc()))
|
||||
all_tools = result.scalars().all()
|
||||
|
||||
user_ids = list(set(tool.user_id for tool in all_tools))
|
||||
tool_ids = [tool.id for tool in all_tools]
|
||||
|
|
@ -214,20 +221,16 @@ class ToolsTable:
|
|||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
result = []
|
||||
for tool in tools:
|
||||
if tool.user_id == user_id:
|
||||
result.append(tool)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='tool',
|
||||
resource_id=tool.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
result.append(tool)
|
||||
return result
|
||||
# One grants query for all non-owned tools instead of one per tool
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='tool',
|
||||
resource_ids=[tool.id for tool in tools if tool.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [tool for tool in tools if tool.user_id == user_id or tool.id in accessible_ids]
|
||||
|
||||
async def get_tool_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
|
||||
try:
|
||||
|
|
@ -301,8 +304,8 @@ class ToolsTable:
|
|||
if access_grants is not None:
|
||||
await AccessGrants.set_access_grants('tool', id, access_grants, db=db)
|
||||
|
||||
tool = await db.get(Tool, id)
|
||||
await db.refresh(tool)
|
||||
# populate_existing: the Core update above bypasses any identity-map copy
|
||||
tool = await db.get(Tool, id, populate_existing=True)
|
||||
return await self._to_tool_model(tool, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
|||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.utils.misc import throttle
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
|
|
@ -69,6 +69,7 @@ class User(Base): # identity & profile
|
|||
|
||||
# Metadata
|
||||
info = Column(JSON, nullable=True)
|
||||
variables = Column(JSON, nullable=True)
|
||||
settings = Column(JSON, nullable=True)
|
||||
oauth = Column(JSON, nullable=True)
|
||||
scim = Column(JSON, nullable=True)
|
||||
|
|
@ -105,6 +106,7 @@ class UserModel(BaseModel):
|
|||
status_expires_at: int | None = None
|
||||
|
||||
info: dict | None = None
|
||||
variables: dict = Field(default_factory=dict, exclude=True)
|
||||
settings: UserSettings | None = None
|
||||
|
||||
oauth: dict | None = None
|
||||
|
|
@ -126,6 +128,11 @@ class UserModel(BaseModel):
|
|||
self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id)
|
||||
return self
|
||||
|
||||
@field_validator('variables', mode='before')
|
||||
@classmethod
|
||||
def normalize_variables(cls, value):
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
class UserStatusModel(UserModel):
|
||||
is_active: bool = False
|
||||
|
|
@ -302,7 +309,6 @@ class UsersTable:
|
|||
result = User(**user.model_dump())
|
||||
session.add(result)
|
||||
await session.commit()
|
||||
await session.refresh(result)
|
||||
return user if result else None
|
||||
|
||||
# database read methods
|
||||
|
|
@ -582,7 +588,6 @@ class UsersTable:
|
|||
return None
|
||||
user.role = role
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def update_user_status_by_id(
|
||||
|
|
@ -595,7 +600,6 @@ class UsersTable:
|
|||
for key, value in form_data.model_dump(exclude_none=True).items():
|
||||
setattr(user, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def update_user_profile_image_url_by_id(
|
||||
|
|
@ -615,7 +619,6 @@ class UsersTable:
|
|||
return None
|
||||
user.profile_image_url = profile_image_url
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
|
||||
|
|
@ -636,7 +639,6 @@ class UsersTable:
|
|||
oauth[provider] = {'sub': sub}
|
||||
user.oauth = oauth
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def update_user_scim_by_id(
|
||||
|
|
@ -655,7 +657,6 @@ class UsersTable:
|
|||
scim[provider] = {'external_id': external_id}
|
||||
user.scim = scim
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def update_user_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> UserModel | None:
|
||||
|
|
@ -666,7 +667,6 @@ class UsersTable:
|
|||
for key, value in updated.items():
|
||||
setattr(user, key, value)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
# settings update helper
|
||||
|
|
@ -681,7 +681,6 @@ class UsersTable:
|
|||
user_settings.update(updated)
|
||||
user.settings = user_settings
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
|
||||
async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
|
||||
|
|
@ -728,8 +727,8 @@ class UsersTable:
|
|||
|
||||
async def get_valid_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[str]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(select(User).where(User.id.in_(user_ids)))
|
||||
return [u.id for u in result.scalars().all()]
|
||||
result = await session.execute(select(User.id).where(User.id.in_(user_ids)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_super_admin_user(self, db: AsyncSession | None = None) -> UserModel | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ from open_webui.retrieval.web.utils import get_web_loader
|
|||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.misc import get_content_from_message, get_message_list
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -73,6 +73,20 @@ LOADER_CONFIG_KEYS = {
|
|||
'web_loader_ssl_verification': 'web.loader.ssl_verification',
|
||||
'web_loader_concurrent_requests': 'web.loader.concurrent_requests',
|
||||
'web_search_trust_env': 'web.search.trust_env',
|
||||
'web_loader_engine': 'web.loader.engine',
|
||||
'web_loader_timeout': 'web.loader.timeout',
|
||||
'playwright_ws_url': 'web.loader.playwright_ws_url',
|
||||
'playwright_timeout': 'web.loader.playwright_timeout',
|
||||
'firecrawl_api_key': 'web.loader.firecrawl_api_key',
|
||||
'firecrawl_api_url': 'web.loader.firecrawl_api_url',
|
||||
'firecrawl_timeout': 'web.loader.firecrawl_timeout',
|
||||
'tavily_api_key': 'web.search.tavily_api_key',
|
||||
'tavily_extract_depth': 'web.search.tavily_extract_depth',
|
||||
'microsoft_web_iq_api_base_url': 'web.search.microsoft_web_iq_api_base_url',
|
||||
'microsoft_web_iq_api_key': 'web.search.microsoft_web_iq_api_key',
|
||||
'microsoft_web_iq_language': 'web.search.microsoft_web_iq_language',
|
||||
'external_web_loader_url': 'web.loader.external_web_loader_url',
|
||||
'external_web_loader_api_key': 'web.loader.external_web_loader_api_key',
|
||||
'CONTENT_EXTRACTION_ENGINE': 'rag.content_extraction_engine',
|
||||
'DATALAB_MARKER_API_KEY': 'rag.datalab_marker_api_key',
|
||||
'DATALAB_MARKER_API_BASE_URL': 'rag.datalab_marker_api_base_url',
|
||||
|
|
@ -128,6 +142,7 @@ def get_loader(request, url: str, config: dict):
|
|||
verify_ssl=config.get('web_loader_ssl_verification'),
|
||||
requests_per_second=config.get('web_loader_concurrent_requests'),
|
||||
trust_env=config.get('web_search_trust_env'),
|
||||
loader_config=config,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1409,7 +1424,10 @@ async def get_sources_from_items(
|
|||
# Reconstruct the message list in order
|
||||
message_list = get_message_list(messages_map, message_id)
|
||||
message_history = '\n'.join(
|
||||
[f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list]
|
||||
[
|
||||
f'#### {m.get("role", "user").capitalize()}\n{get_content_from_message(m) or ""}\n'
|
||||
for m in message_list
|
||||
]
|
||||
)
|
||||
|
||||
# User has access to the chat
|
||||
|
|
|
|||
|
|
@ -158,6 +158,11 @@ def _ssrf_safe_new_conn(self):
|
|||
if getattr(self, 'source_address', None):
|
||||
sock.bind(self.source_address)
|
||||
for opt in getattr(self, 'socket_options', None) or ():
|
||||
if len(opt) == 4 and isinstance(opt[3], str):
|
||||
# urllib3-future per-protocol form: (level, optname, value, "tcp"/"udp")
|
||||
if opt[3].lower() == 'tcp':
|
||||
sock.setsockopt(*opt[:3])
|
||||
continue
|
||||
sock.setsockopt(*opt)
|
||||
sock.connect(sa)
|
||||
return sock
|
||||
|
|
@ -785,13 +790,11 @@ class SafeWebBaseLoader(WebBaseLoader):
|
|||
final_results = []
|
||||
for i, result in enumerate(results):
|
||||
url = urls[i]
|
||||
if parser is None:
|
||||
if url.endswith('.xml'):
|
||||
parser = 'xml'
|
||||
else:
|
||||
parser = self.default_parser
|
||||
self._check_parser(parser)
|
||||
final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs))
|
||||
url_parser = parser
|
||||
if url_parser is None:
|
||||
url_parser = 'xml' if url.endswith('.xml') else self.default_parser
|
||||
self._check_parser(url_parser)
|
||||
final_results.append(BeautifulSoup(result, url_parser, **self.bs_kwargs))
|
||||
return final_results
|
||||
|
||||
async def ascrape_all(self, urls: List[str], parser: Union[str, None] = None) -> List[Any]:
|
||||
|
|
@ -838,6 +841,7 @@ def get_web_loader(
|
|||
verify_ssl: bool = True,
|
||||
requests_per_second: int = 2,
|
||||
trust_env: bool = False,
|
||||
loader_config: Optional[dict] = None,
|
||||
):
|
||||
# Check if the URLs are valid
|
||||
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
|
||||
|
|
@ -846,6 +850,16 @@ def get_web_loader(
|
|||
log.warning(f'All provided URLs were blocked or invalid: {urls}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
loader_config = loader_config or {}
|
||||
|
||||
def cfg(key, env_value):
|
||||
# Admin-saved DB value wins; env constant covers keys never saved.
|
||||
value = loader_config.get(key)
|
||||
return env_value if value is None else value
|
||||
|
||||
engine = cfg('web_loader_engine', WEB_LOADER_ENGINE)
|
||||
web_loader_timeout = cfg('web_loader_timeout', WEB_LOADER_TIMEOUT)
|
||||
|
||||
web_loader_args = {
|
||||
'web_paths': safe_urls,
|
||||
'verify_ssl': verify_ssl,
|
||||
|
|
@ -854,13 +868,15 @@ def get_web_loader(
|
|||
'trust_env': trust_env,
|
||||
}
|
||||
|
||||
if WEB_LOADER_ENGINE == '' or WEB_LOADER_ENGINE == 'safe_web':
|
||||
WebLoaderClass = None
|
||||
|
||||
if engine == '' or engine == 'safe_web':
|
||||
WebLoaderClass = SafeWebBaseLoader
|
||||
|
||||
request_kwargs = {}
|
||||
if WEB_LOADER_TIMEOUT:
|
||||
if web_loader_timeout:
|
||||
try:
|
||||
timeout_value = float(WEB_LOADER_TIMEOUT)
|
||||
timeout_value = float(web_loader_timeout)
|
||||
except ValueError:
|
||||
timeout_value = None
|
||||
|
||||
|
|
@ -870,42 +886,44 @@ def get_web_loader(
|
|||
if request_kwargs:
|
||||
web_loader_args['requests_kwargs'] = request_kwargs
|
||||
|
||||
if WEB_LOADER_ENGINE == 'playwright':
|
||||
if engine == 'playwright':
|
||||
WebLoaderClass = SafePlaywrightURLLoader
|
||||
web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT
|
||||
if PLAYWRIGHT_WS_URL:
|
||||
web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL
|
||||
web_loader_args['playwright_timeout'] = cfg('playwright_timeout', PLAYWRIGHT_TIMEOUT)
|
||||
playwright_ws_url = cfg('playwright_ws_url', PLAYWRIGHT_WS_URL)
|
||||
if playwright_ws_url:
|
||||
web_loader_args['playwright_ws_url'] = playwright_ws_url
|
||||
|
||||
if WEB_LOADER_ENGINE == 'firecrawl':
|
||||
if engine == 'firecrawl':
|
||||
WebLoaderClass = SafeFireCrawlLoader
|
||||
web_loader_args['api_key'] = FIRECRAWL_API_KEY
|
||||
web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL
|
||||
if FIRECRAWL_TIMEOUT:
|
||||
web_loader_args['api_key'] = cfg('firecrawl_api_key', FIRECRAWL_API_KEY)
|
||||
web_loader_args['api_url'] = cfg('firecrawl_api_url', FIRECRAWL_API_BASE_URL)
|
||||
firecrawl_timeout = cfg('firecrawl_timeout', FIRECRAWL_TIMEOUT)
|
||||
if firecrawl_timeout:
|
||||
try:
|
||||
web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT)
|
||||
web_loader_args['timeout'] = int(firecrawl_timeout)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if WEB_LOADER_ENGINE == 'tavily':
|
||||
if engine == 'tavily':
|
||||
WebLoaderClass = SafeTavilyLoader
|
||||
web_loader_args['api_key'] = TAVILY_API_KEY
|
||||
web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH
|
||||
web_loader_args['api_key'] = cfg('tavily_api_key', TAVILY_API_KEY)
|
||||
web_loader_args['extract_depth'] = cfg('tavily_extract_depth', TAVILY_EXTRACT_DEPTH)
|
||||
|
||||
if WEB_LOADER_ENGINE == 'microsoft_web_iq':
|
||||
if engine == 'microsoft_web_iq':
|
||||
WebLoaderClass = SafeMicrosoftWebIQLoader
|
||||
web_loader_args['api_base_url'] = MICROSOFT_WEB_IQ_API_BASE_URL
|
||||
web_loader_args['api_key'] = MICROSOFT_WEB_IQ_API_KEY
|
||||
web_loader_args['language'] = MICROSOFT_WEB_IQ_LANGUAGE
|
||||
if WEB_LOADER_TIMEOUT:
|
||||
web_loader_args['api_base_url'] = cfg('microsoft_web_iq_api_base_url', MICROSOFT_WEB_IQ_API_BASE_URL)
|
||||
web_loader_args['api_key'] = cfg('microsoft_web_iq_api_key', MICROSOFT_WEB_IQ_API_KEY)
|
||||
web_loader_args['language'] = cfg('microsoft_web_iq_language', MICROSOFT_WEB_IQ_LANGUAGE)
|
||||
if web_loader_timeout:
|
||||
try:
|
||||
web_loader_args['timeout'] = int(WEB_LOADER_TIMEOUT)
|
||||
web_loader_args['timeout'] = int(web_loader_timeout)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if WEB_LOADER_ENGINE == 'external':
|
||||
if engine == 'external':
|
||||
WebLoaderClass = ExternalWebLoader
|
||||
web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL
|
||||
web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY
|
||||
web_loader_args['external_url'] = cfg('external_web_loader_url', EXTERNAL_WEB_LOADER_URL)
|
||||
web_loader_args['external_api_key'] = cfg('external_web_loader_api_key', EXTERNAL_WEB_LOADER_API_KEY)
|
||||
|
||||
if WebLoaderClass:
|
||||
web_loader = WebLoaderClass(**web_loader_args)
|
||||
|
|
@ -919,6 +937,6 @@ def get_web_loader(
|
|||
return web_loader
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE}. '
|
||||
f'Invalid WEB_LOADER_ENGINE: {engine}. '
|
||||
"Please set it to 'safe_web', 'playwright', 'firecrawl', 'tavily', 'external', or 'microsoft_web_iq'."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
BYPASS_PYDUB_PREPROCESSING,
|
||||
DEVICE_TYPE,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
|
|
@ -678,15 +679,19 @@ async def _transcribe_openai(request, file_path, filename, languages, file_dir,
|
|||
for key, value in payload.items():
|
||||
form_data.add_field(key, str(value))
|
||||
|
||||
with open(file_path, 'rb') as audio_file:
|
||||
form_data.add_field('file', audio_file, filename=filename)
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
form_data.add_field('file', audio_chunks(), filename=filename)
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
)
|
||||
if r.status == 200:
|
||||
break
|
||||
|
||||
|
|
@ -823,13 +828,18 @@ async def _transcribe_azure(request, file_path, filename, file_dir, id):
|
|||
base_url or f'https://{region}.api.cognitive.microsoft.com'
|
||||
) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15'
|
||||
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field('definition', definition)
|
||||
form_data.add_field('audio', open(file_path, 'rb'), filename=filename)
|
||||
|
||||
r = None
|
||||
try:
|
||||
session = await get_session()
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field('definition', definition)
|
||||
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field('audio', audio_chunks(), filename=filename)
|
||||
r = await session.post(
|
||||
url=endpoint,
|
||||
data=form_data,
|
||||
|
|
@ -1002,7 +1012,12 @@ async def _transcribe_mistral(request, file_path, filename, metadata, file_dir,
|
|||
if language:
|
||||
form_data.add_field('language', language)
|
||||
|
||||
form_data.add_field('file', open(file_path, 'rb'), filename=filename, content_type=mime_type)
|
||||
async def audio_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as audio_file:
|
||||
while chunk := await audio_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field('file', audio_chunks(), filename=filename, content_type=mime_type)
|
||||
|
||||
r = await session.post(
|
||||
url=f'{api_base_url}/audio/transcriptions',
|
||||
|
|
@ -1094,7 +1109,7 @@ async def transcribe(request: Request, file_path: str, metadata: Optional[dict]
|
|||
for chunk_path in chunk_paths:
|
||||
if chunk_path != file_path and os.path.isfile(chunk_path):
|
||||
try:
|
||||
os.remove(chunk_path)
|
||||
await asyncio.to_thread(os.remove, chunk_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -1208,12 +1223,8 @@ async def transcription(
|
|||
if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)):
|
||||
raise ValueError('Invalid file path detected')
|
||||
|
||||
def _write_upload():
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(contents)
|
||||
|
||||
# Audio uploads can be large; write to disk off the event loop.
|
||||
await asyncio.to_thread(_write_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(contents)
|
||||
|
||||
try:
|
||||
metadata = None
|
||||
|
|
|
|||
|
|
@ -1500,12 +1500,13 @@ async def update_message_by_id(
|
|||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and message.user_id != user.id
|
||||
and not await channel_has_access(user.id, channel, permission='write', strict=False, db=db)
|
||||
if user.role != 'admin' and not await channel_has_access(
|
||||
user.id, channel, permission='write', strict=False, db=db
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
# Write access is not authorship — block cross-member edits.
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
await Messages.update_message_by_id(message_id, form_data, db=db)
|
||||
|
|
@ -1725,18 +1726,17 @@ async def delete_message_by_id(
|
|||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and message.user_id != user.id
|
||||
and not await channel_has_access(
|
||||
user.id,
|
||||
channel,
|
||||
permission='write',
|
||||
strict=False,
|
||||
db=db,
|
||||
)
|
||||
if user.role != 'admin' and not await channel_has_access(
|
||||
user.id,
|
||||
channel,
|
||||
permission='write',
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
# Write access is not authorship — block cross-member deletes.
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
try:
|
||||
await Messages.delete_message_by_id(message_id, db=db)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ CHAT_CONFIG_KEYS = {
|
|||
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
|
||||
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
|
||||
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage',
|
||||
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +77,7 @@ class ChatConfigForm(BaseModel):
|
|||
ENABLE_CONTEXT_COMPACTION: bool
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
|
||||
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
|
||||
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
|
||||
|
||||
|
||||
|
|
@ -125,6 +127,8 @@ async def get_chat_config_values() -> dict:
|
|||
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
|
||||
if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None:
|
||||
config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000)
|
||||
if config.get('CONTEXT_COMPACTION_RETENTION_PERCENTAGE') is None:
|
||||
config['CONTEXT_COMPACTION_RETENTION_PERCENTAGE'] = 40
|
||||
return config
|
||||
|
||||
|
||||
|
|
@ -738,12 +742,14 @@ async def get_chat_config(user=Depends(get_admin_user)):
|
|||
async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
|
||||
threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
|
||||
token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold))
|
||||
retention_percentage = min(50, max(10, int(form_data.CONTEXT_COMPACTION_RETENTION_PERCENTAGE)))
|
||||
await Config.upsert(
|
||||
chat_config_updates(
|
||||
{
|
||||
**form_data.model_dump(),
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
|
||||
'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
|
||||
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': retention_percentage,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -1203,30 +1209,31 @@ async def compact_chat_by_id(
|
|||
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
|
||||
if not chat:
|
||||
# Check if user has access via access grants (shared_chat grants)
|
||||
if user.role == 'admin':
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
|
||||
chat = candidate
|
||||
else:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
if not chat and user.role == 'admin':
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
|
||||
chat = candidate
|
||||
|
||||
# Check folder-based access (shared folders)
|
||||
if not chat:
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if candidate and candidate.folder_id:
|
||||
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
chat = candidate
|
||||
# Access explicitly granted to this user applies to admins too, so an admin
|
||||
# does not lose a chat shared with them when ENABLE_ADMIN_CHAT_ACCESS is off.
|
||||
if not chat:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if has_grant:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
|
||||
# Check folder-based access (shared folders)
|
||||
if not chat:
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if candidate and candidate.folder_id:
|
||||
folder = await Folders.get_folder_by_id(candidate.folder_id, db=db)
|
||||
if folder and await has_folder_access(user.id, folder, 'read', db):
|
||||
chat = candidate
|
||||
|
||||
if chat:
|
||||
data = ChatResponse(**chat.model_dump()).model_dump()
|
||||
|
|
@ -1259,6 +1266,8 @@ async def update_chat_by_id(
|
|||
)
|
||||
|
||||
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
|
||||
if form_data.variables is not None:
|
||||
chat = await Chats.update_chat_variables_by_id(id, form_data.variables, db=db) or chat
|
||||
|
||||
# Reconcile chat_message rows without inferring deletes from missing IDs.
|
||||
# Message deletion has its own endpoint below.
|
||||
|
|
@ -1642,6 +1651,9 @@ async def fork_chat_by_id(
|
|||
internal_meta=meta,
|
||||
)
|
||||
|
||||
if fork and chat.variables:
|
||||
fork = await Chats.update_chat_variables_by_id(fork.id, chat.variables, db=db, touch=False) or fork
|
||||
|
||||
if not fork:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
|
|
@ -1684,6 +1696,7 @@ async def clone_chat_by_id(
|
|||
**{
|
||||
'chat': updated_chat,
|
||||
'meta': chat.meta,
|
||||
'variables': chat.variables or {},
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
}
|
||||
|
|
@ -1767,6 +1780,7 @@ async def clone_shared_chat_by_id(
|
|||
**{
|
||||
'chat': updated_chat,
|
||||
'meta': chat.meta,
|
||||
'variables': chat.variables or {},
|
||||
'pinned': chat.pinned,
|
||||
'folder_id': chat.folder_id,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ async def create_feedback(
|
|||
EVENTS.FEEDBACK_CREATED,
|
||||
actor=user,
|
||||
subject_id=feedback.id,
|
||||
data={'rating': getattr(feedback, 'rating', None)},
|
||||
data={'rating': (feedback.data or {}).get('rating')},
|
||||
)
|
||||
return feedback
|
||||
|
||||
|
|
@ -463,7 +463,7 @@ async def update_feedback_by_id(
|
|||
EVENTS.FEEDBACK_UPDATED,
|
||||
actor=user,
|
||||
subject_id=feedback.id,
|
||||
data={'rating': getattr(feedback, 'rating', None)},
|
||||
data={'rating': (feedback.data or {}).get('rating')},
|
||||
)
|
||||
return feedback
|
||||
|
||||
|
|
|
|||
|
|
@ -76,11 +76,12 @@ async def get_folders(
|
|||
await check_folders_permission(request, user, db=db)
|
||||
|
||||
folders = await Folders.get_folders_by_user_id(user.id, db=db)
|
||||
folder_ids = {folder.id for folder in folders}
|
||||
|
||||
# Verify folder data integrity
|
||||
folder_list = []
|
||||
for folder in folders:
|
||||
if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db):
|
||||
if folder.parent_id and folder.parent_id not in folder_ids:
|
||||
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
|
||||
|
||||
if folder.data and 'files' in folder.data:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from types import SimpleNamespace
|
|||
from typing import Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
|
@ -940,10 +941,10 @@ async def image_edits(
|
|||
if isinstance(file_response, FileResponse):
|
||||
file_path = file_response.path
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
image_data = base64.b64encode(file_bytes).decode('utf-8')
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
image_data = base64.b64encode(file_bytes).decode('utf-8')
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
|
||||
return f'data:{mime_type};base64,{image_data}'
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from open_webui.models.models import (
|
|||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -48,6 +49,14 @@ log = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def add_chat_variables_schema(model_dict: dict) -> dict:
|
||||
system = ((model_dict.get('params') or {}).get('system') if isinstance(model_dict.get('params'), dict) else None)
|
||||
schema = get_chat_variables_schema(system)
|
||||
if schema:
|
||||
model_dict.setdefault('meta', {})['chat_variables_schema'] = schema
|
||||
return model_dict
|
||||
|
||||
|
||||
def _safe_static_redirect_path(url: str) -> str | None:
|
||||
"""
|
||||
If url is a same-origin static asset path, return a normalized path safe for
|
||||
|
|
@ -177,7 +186,7 @@ async def get_models(
|
|||
# Strip profile_image_url from meta — images are served via /model/profile/image.
|
||||
items = []
|
||||
for model in result.items:
|
||||
data = model.model_dump()
|
||||
data = add_chat_variables_schema(model.model_dump())
|
||||
if data.get('meta'):
|
||||
data['meta'].pop('profile_image_url', None)
|
||||
items.append(
|
||||
|
|
@ -527,6 +536,7 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
|
|||
db=db,
|
||||
):
|
||||
model_dict = model.model_dump()
|
||||
model_dict = add_chat_variables_schema(model_dict)
|
||||
# Strip params (system prompt and other admin-curated config)
|
||||
# for read-only callers — matches the params strip already
|
||||
# enforced on /api/models in utils/models.py. Owners, admins
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from datetime import datetime
|
|||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
|
|
@ -25,6 +26,7 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
|
|
@ -1529,7 +1531,7 @@ async def download_file_stream(
|
|||
file_url: str,
|
||||
file_path: str,
|
||||
file_name: str,
|
||||
chunk_size: int = 1024 * 1024,
|
||||
chunk_size: int = AIOHTTP_FILE_STREAM_CHUNK_SIZE,
|
||||
):
|
||||
"""Stream a model file download from *file_url*, then push the blob to Ollama."""
|
||||
current_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||
|
|
@ -1544,37 +1546,38 @@ async def download_file_stream(
|
|||
) as response:
|
||||
total_size = int(response.headers.get('content-length', 0)) + current_size
|
||||
|
||||
with open(file_path, 'ab+') as f:
|
||||
async with aiofiles.open(file_path, 'ab') as f:
|
||||
async for data in response.content.iter_chunked(chunk_size):
|
||||
current_size += len(data)
|
||||
f.write(data)
|
||||
await f.write(data)
|
||||
|
||||
done = current_size == total_size
|
||||
progress = round((current_size / total_size) * 100, 2)
|
||||
progress_total = total_size or current_size
|
||||
progress = round((current_size / progress_total) * 100, 2)
|
||||
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
|
||||
|
||||
if done:
|
||||
f.close()
|
||||
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
|
||||
done = True
|
||||
hashed = await asyncio.to_thread(calculate_sha256, file_path, chunk_size)
|
||||
|
||||
def _read_blob():
|
||||
with open(file_path, 'rb') as blob_f:
|
||||
return blob_f.read()
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
|
||||
blob_size = await asyncio.to_thread(os.path.getsize, file_path)
|
||||
|
||||
blob_data = await asyncio.to_thread(_read_blob)
|
||||
async def blob_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as blob_file:
|
||||
while chunk := await blob_file.read(chunk_size):
|
||||
yield chunk
|
||||
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{hashed}'
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as blob_resp:
|
||||
if blob_resp.ok:
|
||||
os.remove(file_path)
|
||||
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
|
||||
else:
|
||||
raise RuntimeError('Ollama: Could not create blob, Please try again.')
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_chunks(),
|
||||
headers={'Content-Length': str(blob_size)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as blob_resp:
|
||||
if blob_resp.ok:
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
yield f'data: {json.dumps({"done": done, "blob": f"sha256:{hashed}", "name": file_name})}\n\n'
|
||||
else:
|
||||
raise RuntimeError('Ollama: Could not create blob, Please try again.')
|
||||
|
||||
|
||||
@router.post('/models/download')
|
||||
|
|
@ -1621,17 +1624,11 @@ async def upload_model(
|
|||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
# Stage 1: persist the uploaded file to disk
|
||||
chunk_size = 1024 * 1024 * 2 # 2 MiB
|
||||
chunk_size = AIOHTTP_FILE_STREAM_CHUNK_SIZE
|
||||
|
||||
def _persist_upload():
|
||||
with open(file_path, 'wb') as out_f:
|
||||
while True:
|
||||
chunk = file.file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
out_f.write(chunk)
|
||||
|
||||
await asyncio.to_thread(_persist_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as out_f:
|
||||
while chunk := await file.read(chunk_size):
|
||||
await out_f.write(chunk)
|
||||
|
||||
async def file_process_stream():
|
||||
nonlocal ollama_url
|
||||
|
|
@ -1643,25 +1640,26 @@ async def upload_model(
|
|||
log.info(f'Model Hash: {file_hash}')
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
bytes_read = 0
|
||||
while chunk := f.read(chunk_size):
|
||||
bytes_read = 0
|
||||
async with aiofiles.open(file_path, 'rb') as f:
|
||||
while chunk := await f.read(chunk_size):
|
||||
bytes_read += len(chunk)
|
||||
progress = round(bytes_read / total_size * 100, 2)
|
||||
yield f'data: {json.dumps({"progress": progress, "total": total_size, "completed": bytes_read})}\n\n'
|
||||
|
||||
# Stage 3: push blob to Ollama
|
||||
def _read_blob():
|
||||
with open(file_path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
blob_data = await asyncio.to_thread(_read_blob)
|
||||
event = json.dumps({'progress': progress, 'total': total_size, 'completed': bytes_read})
|
||||
yield f'data: {event}\n\n'
|
||||
|
||||
session = await get_session()
|
||||
blob_url = f'{ollama_url}/api/blobs/sha256:{file_hash}'
|
||||
|
||||
async def blob_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as blob_file:
|
||||
while chunk := await blob_file.read(chunk_size):
|
||||
yield chunk
|
||||
|
||||
async with session.post(
|
||||
blob_url,
|
||||
data=blob_data,
|
||||
data=blob_chunks(),
|
||||
headers={'Content-Length': str(total_size)},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as resp:
|
||||
|
|
@ -1669,7 +1667,7 @@ async def upload_model(
|
|||
raise Exception('Ollama: Could not create blob, Please try again.')
|
||||
|
||||
log.info('Uploaded to /api/blobs')
|
||||
os.remove(file_path)
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
|
||||
# Stage 4: create the model
|
||||
model, _ext = os.path.splitext(filename)
|
||||
|
|
@ -1690,7 +1688,10 @@ async def upload_model(
|
|||
) as create_resp:
|
||||
if create_resp.ok:
|
||||
log.info('API SUCCESS!')
|
||||
yield f'data: {json.dumps({"done": True, "blob": f"sha256:{file_hash}", "name": filename, "model_created": model})}\n\n'
|
||||
event = json.dumps(
|
||||
{'done': True, 'blob': f'sha256:{file_hash}', 'name': filename, 'model_created': model}
|
||||
)
|
||||
yield f'data: {event}\n\n'
|
||||
else:
|
||||
resp_text = await create_resp.text()
|
||||
raise Exception(f'Failed to create model in Ollama. {resp_text}')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import re
|
|||
from typing import Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
||||
|
|
@ -491,13 +492,12 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
|
||||
r.raise_for_status()
|
||||
|
||||
# Save the streaming content to a file
|
||||
with open(file_path, 'wb') as f:
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
async for chunk in r.content.iter_chunked(8192):
|
||||
f.write(chunk)
|
||||
await f.write(chunk)
|
||||
|
||||
with open(file_body_path, 'w') as f:
|
||||
json.dump(json.loads(body.decode('utf-8')), f)
|
||||
async with aiofiles.open(file_body_path, 'w') as f:
|
||||
await f.write(json.dumps(json.loads(body.decode('utf-8'))))
|
||||
|
||||
# Return the saved file
|
||||
return FileResponse(file_path)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -18,7 +18,7 @@ from fastapi import (
|
|||
)
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_FILE_STREAM_CHUNK_SIZE
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.routers.openai import get_all_models_responses
|
||||
|
|
@ -237,35 +237,37 @@ async def upload_pipeline(
|
|||
|
||||
response = None
|
||||
try:
|
||||
# Save the uploaded file off the event loop (uploads can be large).
|
||||
def _save_upload():
|
||||
with open(file_path, 'wb') as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
await asyncio.to_thread(_save_upload)
|
||||
async with aiofiles.open(file_path, 'wb') as buffer:
|
||||
while chunk := await file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
await buffer.write(chunk)
|
||||
|
||||
url, key = await get_openai_connection(urlIdx)
|
||||
|
||||
headers = {'Authorization': f'Bearer {key}'}
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
with open(file_path, 'rb') as f:
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field(
|
||||
'file',
|
||||
f,
|
||||
filename=filename,
|
||||
content_type='application/octet-stream',
|
||||
)
|
||||
form_data = aiohttp.FormData()
|
||||
|
||||
async with session.post(
|
||||
f'{url}/pipelines/upload',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
async def pipeline_chunks():
|
||||
async with aiofiles.open(file_path, 'rb') as pipeline_file:
|
||||
while chunk := await pipeline_file.read(AIOHTTP_FILE_STREAM_CHUNK_SIZE):
|
||||
yield chunk
|
||||
|
||||
form_data.add_field(
|
||||
'file',
|
||||
pipeline_chunks(),
|
||||
filename=filename,
|
||||
content_type='application/octet-stream',
|
||||
)
|
||||
|
||||
async with session.post(
|
||||
f'{url}/pipelines/upload',
|
||||
headers=headers,
|
||||
data=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.json()
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -297,7 +299,7 @@ async def upload_pipeline(
|
|||
finally:
|
||||
# Ensure the file is deleted after the upload is completed or on failure
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
await asyncio.to_thread(os.remove, file_path)
|
||||
|
||||
|
||||
class AddPipelineForm(BaseModel):
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TIKTOKEN_DISALLOWED_SPECIAL = ()
|
||||
|
||||
##########################################
|
||||
#
|
||||
# Utility functions
|
||||
|
|
@ -1582,7 +1584,7 @@ def get_splitter_length_function(
|
|||
) -> Callable[[str], int]:
|
||||
if config.TEXT_SPLITTER == 'token':
|
||||
encoding = tiktoken.get_encoding(str(config.TIKTOKEN_ENCODING_NAME))
|
||||
return lambda text: len(encoding.encode(text, disallowed_special=()))
|
||||
return lambda text: len(encoding.encode(text, disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL))
|
||||
|
||||
if config.TEXT_SPLITTER == 'token_transformers':
|
||||
tokenizer = get_transformers_tokenizer(request, config)
|
||||
|
|
@ -1689,6 +1691,7 @@ def save_docs_to_vector_db(
|
|||
chunk_size=config.CHUNK_SIZE,
|
||||
chunk_overlap=config.CHUNK_OVERLAP,
|
||||
add_start_index=True,
|
||||
disallowed_special=TIKTOKEN_DISALLOWED_SPECIAL,
|
||||
)
|
||||
docs = text_splitter.split_documents(docs)
|
||||
elif config.TEXT_SPLITTER == 'token_transformers':
|
||||
|
|
@ -2609,6 +2612,7 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
|
|||
verify_ssl=config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
requests_per_second=config.WEB_LOADER_CONCURRENT_REQUESTS,
|
||||
trust_env=config.WEB_SEARCH_TRUST_ENV,
|
||||
loader_config=await get_loader_config(),
|
||||
)
|
||||
docs = await loader.aload()
|
||||
|
||||
|
|
@ -2910,21 +2914,24 @@ async def reset_upload_dir(request: Request, user=Depends(get_admin_user)) -> bo
|
|||
folder = f'{UPLOAD_DIR}'
|
||||
try:
|
||||
# Check if the directory exists
|
||||
if os.path.exists(folder):
|
||||
if await asyncio.to_thread(os.path.exists, folder):
|
||||
# Iterate over all the files and directories in the specified directory
|
||||
for filename in os.listdir(folder):
|
||||
for filename in await asyncio.to_thread(os.listdir, folder):
|
||||
file_path = os.path.join(folder, filename)
|
||||
try:
|
||||
if os.path.isfile(file_path) or os.path.islink(file_path):
|
||||
os.unlink(file_path) # Remove the file or link
|
||||
elif os.path.isdir(file_path):
|
||||
shutil.rmtree(file_path) # Remove the directory
|
||||
if await asyncio.to_thread(os.path.isfile, file_path) or await asyncio.to_thread(
|
||||
os.path.islink, file_path
|
||||
):
|
||||
await asyncio.to_thread(os.unlink, file_path) # Remove the file or link
|
||||
elif await asyncio.to_thread(os.path.isdir, file_path):
|
||||
await asyncio.to_thread(shutil.rmtree, file_path) # Remove the directory
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to delete {file_path}. Reason: {e}')
|
||||
else:
|
||||
log.warning(f'The directory {folder} does not exist')
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to process the directory {folder}. Reason: {e}')
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.RETRIEVAL_UPLOADS_RESET,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from open_webui.utils.auth import (
|
|||
get_verified_user,
|
||||
validate_password,
|
||||
)
|
||||
from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -207,6 +208,7 @@ class SharingPermissions(BaseModel):
|
|||
|
||||
class AccessGrantsPermissions(BaseModel):
|
||||
allow_users: bool = True
|
||||
allow_groups: bool = True
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
|
|
@ -573,6 +575,52 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
|
|||
return user.info
|
||||
|
||||
|
||||
class UserVariablesForm(BaseModel):
|
||||
variables: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserVariablesResponse(BaseModel):
|
||||
variables: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserVariablesBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/user/variables', response_model=UserVariablesResponse)
|
||||
async def get_user_variables_by_session_user(user=Depends(get_verified_user)):
|
||||
return UserVariablesResponse(variables=normalize_user_variables(user.variables))
|
||||
|
||||
|
||||
############################
|
||||
# UpdateUserVariablesBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.post('/user/variables/update', response_model=UserVariablesResponse)
|
||||
async def update_user_variables_by_session_user(
|
||||
form_data: UserVariablesForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
variables = validate_user_variables(form_data.variables)
|
||||
except ChatVariablesError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
return UserVariablesResponse(variables=variables)
|
||||
|
||||
|
||||
############################
|
||||
# UpdateUserInfoBySessionUser
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import logging
|
|||
import random
|
||||
import sys
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
import pycrdt as Y
|
||||
import socketio
|
||||
|
|
@ -16,7 +15,6 @@ from open_webui.env import (
|
|||
ENABLE_WEBSOCKET_SUPPORT,
|
||||
GLOBAL_LOG_LEVEL,
|
||||
REDIS_KEY_PREFIX,
|
||||
VERSION,
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
||||
WEBSOCKET_MANAGER,
|
||||
WEBSOCKET_REDIS_CLUSTER,
|
||||
|
|
@ -44,7 +42,6 @@ from open_webui.utils.redis import (
|
|||
get_redis_connection,
|
||||
get_sentinels_from_env,
|
||||
)
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -58,6 +55,11 @@ REDIS = None
|
|||
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
|
||||
|
||||
|
||||
def get_room_sid_map(manager, namespace: str, room: str):
|
||||
"""Return this process's Socket.IO sid map for a room, without copying it."""
|
||||
return manager.rooms.get(namespace, {}).get(room)
|
||||
|
||||
|
||||
class LocalFilteredRedisManager(socketio.AsyncRedisManager):
|
||||
"""AsyncRedisManager that drops pub/sub emits with no local recipients.
|
||||
|
||||
|
|
@ -76,7 +78,7 @@ class LocalFilteredRedisManager(socketio.AsyncRedisManager):
|
|||
room = message.get('room')
|
||||
if isinstance(room, str):
|
||||
namespace = message.get('namespace') or '/'
|
||||
if next(self.get_participants(namespace, room), None) is None:
|
||||
if not get_room_sid_map(self, namespace, room):
|
||||
return
|
||||
await super()._handle_emit(message)
|
||||
|
||||
|
|
@ -229,17 +231,19 @@ async def periodic_usage_pool_cleanup():
|
|||
try:
|
||||
while True:
|
||||
if not renew_func():
|
||||
log.error(f'Unable to renew cleanup lock. Exiting usage pool cleanup.')
|
||||
log.error('Unable to renew cleanup lock. Exiting usage pool cleanup.')
|
||||
raise Exception('Unable to renew usage pool cleanup lock.')
|
||||
|
||||
now = int(time.time())
|
||||
send_usage = False
|
||||
for model_id, connections in list(USAGE_POOL.items()):
|
||||
# Creating a list of sids to remove if they have timed out
|
||||
expired_sids = [
|
||||
sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION
|
||||
]
|
||||
|
||||
if connections and not expired_sids:
|
||||
continue
|
||||
|
||||
for sid in expired_sids:
|
||||
del connections[sid]
|
||||
|
||||
|
|
@ -248,8 +252,6 @@ async def periodic_usage_pool_cleanup():
|
|||
del USAGE_POOL[model_id]
|
||||
else:
|
||||
USAGE_POOL[model_id] = connections
|
||||
|
||||
send_usage = True
|
||||
await asyncio.sleep(TIMEOUT_DURATION)
|
||||
finally:
|
||||
release_func()
|
||||
|
|
@ -276,24 +278,21 @@ def get_user_id_from_session_pool(sid):
|
|||
|
||||
def get_session_ids_from_room(room):
|
||||
"""Get all session IDs from a specific room."""
|
||||
active_session_ids = sio.manager.get_participants(
|
||||
namespace='/',
|
||||
room=room,
|
||||
)
|
||||
return [session_id[0] for session_id in active_session_ids]
|
||||
members = get_room_sid_map(sio.manager, '/', room)
|
||||
return list(members) if members else []
|
||||
|
||||
|
||||
def get_user_ids_from_room(room):
|
||||
active_session_ids = get_session_ids_from_room(room)
|
||||
|
||||
# Single pool lookup per session (each .get is a Redis round trip
|
||||
# when the session pool is Redis-backed).
|
||||
active_user_ids = list(
|
||||
set(
|
||||
[
|
||||
SESSION_POOL.get(session_id)['id']
|
||||
for session_id in active_session_ids
|
||||
if SESSION_POOL.get(session_id) is not None
|
||||
]
|
||||
)
|
||||
{
|
||||
entry['id']
|
||||
for entry in (SESSION_POOL.get(session_id) for session_id in active_session_ids)
|
||||
if entry is not None
|
||||
}
|
||||
)
|
||||
return active_user_ids
|
||||
|
||||
|
|
@ -357,7 +356,7 @@ async def usage(sid, data):
|
|||
|
||||
# Store the new usage data and task
|
||||
USAGE_POOL[model_id] = {
|
||||
**(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}),
|
||||
**(USAGE_POOL.get(model_id) or {}),
|
||||
sid: {'updated_at': current_time},
|
||||
}
|
||||
|
||||
|
|
@ -404,33 +403,38 @@ async def user_join(sid, data):
|
|||
if token_data is None or 'id' not in token_data or not await is_valid_token(token_data, redis):
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(token_data['id'])
|
||||
if not user:
|
||||
return
|
||||
existing = SESSION_POOL.get(sid)
|
||||
if existing and existing.get('id') == token_data['id']:
|
||||
SESSION_POOL[sid] = {**existing, 'last_seen_at': int(time.time())}
|
||||
user_id, user_name, user_role = existing['id'], existing['name'], existing['role']
|
||||
else:
|
||||
user = await Users.get_user_by_id(token_data['id'])
|
||||
if not user:
|
||||
return
|
||||
|
||||
SESSION_POOL[sid] = {
|
||||
**user.model_dump(
|
||||
exclude=[
|
||||
'profile_image_url',
|
||||
'profile_banner_image_url',
|
||||
'date_of_birth',
|
||||
'bio',
|
||||
'gender',
|
||||
]
|
||||
),
|
||||
'last_seen_at': int(time.time()),
|
||||
}
|
||||
|
||||
await sio.enter_room(sid, f'user:{user.id}')
|
||||
SESSION_POOL[sid] = {
|
||||
**user.model_dump(
|
||||
exclude=[
|
||||
'profile_image_url',
|
||||
'profile_banner_image_url',
|
||||
'date_of_birth',
|
||||
'bio',
|
||||
'gender',
|
||||
]
|
||||
),
|
||||
'last_seen_at': int(time.time()),
|
||||
}
|
||||
await sio.enter_room(sid, f'user:{user.id}')
|
||||
user_id, user_name, user_role = user.id, user.name, user.role
|
||||
|
||||
# Join all the channels only if user has channels permission
|
||||
if user.role == 'admin' or await has_permission(user.id, 'features.channels'):
|
||||
channels = await Channels.get_channels_by_user_id(user.id)
|
||||
if user_role == 'admin' or await has_permission(user_id, 'features.channels'):
|
||||
channels = await Channels.get_channels_by_user_id(user_id)
|
||||
log.debug(f'{channels=}')
|
||||
for channel in channels:
|
||||
await sio.enter_room(sid, f'channel:{channel.id}')
|
||||
|
||||
return {'id': user.id, 'name': user.name}
|
||||
return {'id': user_id, 'name': user_name}
|
||||
|
||||
|
||||
@sio.on('heartbeat')
|
||||
|
|
@ -510,13 +514,7 @@ async def join_note(sid, data):
|
|||
@sio.on('events:channel')
|
||||
async def channel_events(sid, data):
|
||||
room = f'channel:{data["channel_id"]}'
|
||||
participants = sio.manager.get_participants(
|
||||
namespace='/',
|
||||
room=room,
|
||||
)
|
||||
|
||||
sids = [sid for sid, _ in participants]
|
||||
if sid not in sids:
|
||||
if sid not in (get_room_sid_map(sio.manager, '/', room) or {}):
|
||||
return
|
||||
|
||||
event_data = data['data']
|
||||
|
|
@ -859,7 +857,6 @@ async def yjs_awareness_update(sid, data):
|
|||
@sio.event
|
||||
async def disconnect(sid, reason=None):
|
||||
if sid in SESSION_POOL:
|
||||
user = SESSION_POOL[sid]
|
||||
del SESSION_POOL[sid]
|
||||
|
||||
# Clean up USAGE_POOL entries for this session
|
||||
|
|
@ -960,16 +957,19 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
if internal and event_data.get('type') == 'notification':
|
||||
return
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': chat_id,
|
||||
'message_id': message_id,
|
||||
**({'internal': True} if internal else {}),
|
||||
'data': event_data,
|
||||
},
|
||||
room=f'user:{user_id}',
|
||||
)
|
||||
room = f'user:{user_id}'
|
||||
# Local rooms are authoritative; Redis may have listeners on another instance.
|
||||
if WEBSOCKET_MANAGER == 'redis' or room in sio.manager.rooms.get('/', {}):
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': chat_id,
|
||||
'message_id': message_id,
|
||||
**({'internal': True} if internal else {}),
|
||||
'data': event_data,
|
||||
},
|
||||
room=room,
|
||||
)
|
||||
|
||||
if update_db and message_id and not (request_info.get('chat_id') or '').startswith('local:'):
|
||||
event_type = event_data.get('type')
|
||||
|
|
|
|||
|
|
@ -102,12 +102,18 @@ class RedisDict:
|
|||
|
||||
# Serialize values once — reused for both the fingerprint and the write.
|
||||
serialized = {k: json.dumps(v) for k, v in mapping.items()}
|
||||
digest = hashlib.sha256()
|
||||
for key in sorted(serialized):
|
||||
digest.update(key.encode())
|
||||
digest.update(b'\0')
|
||||
digest.update(serialized[key].encode())
|
||||
digest.update(b'\0')
|
||||
signature = digest.hexdigest()
|
||||
|
||||
# Skip the write when the prepared mapping is identical to the last one
|
||||
# this process wrote. The check is per-instance (not distributed), but
|
||||
# still eliminates the majority of redundant writes because each pod
|
||||
# typically produces the same model list on consecutive refreshes.
|
||||
signature = hashlib.sha256(json.dumps(serialized, sort_keys=True).encode()).hexdigest()
|
||||
if signature == self._last_signature:
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ MAX_GREP_MATCHES = 50
|
|||
|
||||
|
||||
def is_regex_pattern(pattern: str) -> bool:
|
||||
"""Detect if a pattern looks like regex (\|, .*, .+, \d, \w, \s, [...])."""
|
||||
"""Detect if a pattern looks like regex (|, .*, .+, \d, \w, \s, [...])."""
|
||||
return (
|
||||
'\|' in pattern
|
||||
'|' in pattern
|
||||
or '.*' in pattern
|
||||
or '.+' in pattern
|
||||
or '.?' in pattern
|
||||
|
|
@ -691,6 +691,10 @@ async def _kb_grep(
|
|||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
matched.append(f'{i}: {line}')
|
||||
if count_only:
|
||||
return str(len(matched))
|
||||
if filenames_only:
|
||||
return '(standard input)' if matched else f'No matches for "{pattern}"'
|
||||
return '\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
|
||||
# Single file grep
|
||||
|
|
|
|||
|
|
@ -276,6 +276,7 @@ async def has_base_model_access(
|
|||
user_id: str,
|
||||
model_info,
|
||||
*,
|
||||
user_role: str | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
db=None,
|
||||
) -> bool:
|
||||
|
|
@ -283,9 +284,11 @@ async def has_base_model_access(
|
|||
Walk the ``base_model_id`` chain and verify the caller has read access
|
||||
at every hop.
|
||||
|
||||
Returns ``True`` when access is granted (or the chain ends at a raw
|
||||
provider model that has no per-model ACL). Returns ``False`` the
|
||||
moment a registered base model denies access.
|
||||
A base model without a ``model`` table row is admin-only, matching how
|
||||
unregistered models are treated for direct use (``get_filtered_models``
|
||||
hides them from non-admins and ``check_model_access`` rejects them), so
|
||||
a shared preset cannot be used to reach a base model the caller could
|
||||
not use directly. Returns ``False`` the moment any hop denies access.
|
||||
"""
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.models import Models
|
||||
|
|
@ -296,7 +299,7 @@ async def has_base_model_access(
|
|||
seen.add(base_model_id)
|
||||
base_model_info = await Models.get_model_by_id(base_model_id, db=db)
|
||||
if base_model_info is None:
|
||||
break # Raw provider model — no per-model ACL
|
||||
return user_role == 'admin'
|
||||
if not (
|
||||
user_id == base_model_info.user_id
|
||||
or await AccessGrants.has_access(
|
||||
|
|
@ -355,7 +358,9 @@ async def check_model_access(
|
|||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
|
||||
# Enforce access on chained base models
|
||||
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids):
|
||||
if not await has_base_model_access(
|
||||
user.id, model_info, user_role=user.role, user_group_ids=user_group_ids
|
||||
):
|
||||
raise HTTPException(status_code=403, detail='Model not found')
|
||||
else:
|
||||
if user.role != 'admin':
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ async def has_access_to_file(
|
|||
access_type: str,
|
||||
user: UserModel,
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has the specified access to a file through any of:
|
||||
|
|
@ -43,7 +44,8 @@ async def has_access_to_file(
|
|||
# the object's OWNER owns that file; otherwise a read-only file laundered into an object
|
||||
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
|
||||
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if (
|
||||
knowledge_base.user_id == user.id
|
||||
|
|
@ -60,12 +62,25 @@ async def has_access_to_file(
|
|||
|
||||
knowledge_base_id = file.meta.get('collection_name') if file.meta else None
|
||||
if knowledge_base_id:
|
||||
knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db)
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.id == knowledge_base_id and (
|
||||
access_type == 'read' or knowledge_base.user_id == file.user_id
|
||||
):
|
||||
return True
|
||||
# Fetch the one referenced knowledge base instead of listing every
|
||||
# knowledge base the user can access just to scan for this id.
|
||||
knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id, db=db)
|
||||
if (
|
||||
knowledge_base
|
||||
and (access_type == 'read' or knowledge_base.user_id == file.user_id)
|
||||
and (
|
||||
knowledge_base.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge_base.id,
|
||||
permission=access_type,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any channels the user has access to
|
||||
channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
|
||||
|
|
@ -88,7 +103,9 @@ async def has_access_to_file(
|
|||
|
||||
# Check if the file is directly attached to a shared workspace model (per the ownership
|
||||
# note above, model write is conferred only for files the model owner owns).
|
||||
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
|
||||
for model in await Models.get_models_by_user_id(
|
||||
user.id, permission=access_type, db=db, user_group_ids=user_group_ids
|
||||
):
|
||||
knowledge_items = getattr(model.meta, 'knowledge', None) or []
|
||||
for item in knowledge_items:
|
||||
if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:
|
||||
|
|
@ -105,14 +122,18 @@ async def get_accessible_folder_files(
|
|||
) -> list[dict]:
|
||||
"""Filter folder.data['files'] entries to those the caller can read.
|
||||
|
||||
Each entry is expected to have 'type' ('file' or 'collection') and 'id'.
|
||||
Admins bypass all checks. Unknown types are kept as-is.
|
||||
Entries carry a 'type' ('file', 'collection' or 'note') and 'id'. File, collection and
|
||||
note ids are each access-checked against the caller; admins bypass all checks and
|
||||
genuinely unknown types are kept as-is.
|
||||
"""
|
||||
if not entries:
|
||||
return []
|
||||
if user.role == 'admin':
|
||||
return list(entries)
|
||||
|
||||
# One group-membership fetch for the whole folder listing
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
|
||||
accessible: list[dict] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
|
|
@ -123,11 +144,27 @@ async def get_accessible_folder_files(
|
|||
accessible.append(entry)
|
||||
continue
|
||||
if entry_type == 'file':
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db):
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids):
|
||||
accessible.append(entry)
|
||||
elif entry_type == 'collection':
|
||||
if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db):
|
||||
accessible.append(entry)
|
||||
elif entry_type == 'note':
|
||||
# Owner has no self-grant (notes are private by default), so check ownership too.
|
||||
from open_webui.models.notes import Notes
|
||||
|
||||
note = await Notes.get_note_by_id(entry_id, db=db)
|
||||
if note and (
|
||||
note.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=entry_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
accessible.append(entry)
|
||||
else:
|
||||
accessible.append(entry)
|
||||
return accessible
|
||||
|
|
|
|||
|
|
@ -100,14 +100,20 @@ class CommitSessionMiddleware:
|
|||
# Downstream did not complete successfully. Roll back any
|
||||
# pending sync writes, release the connection, and let the
|
||||
# exception propagate.
|
||||
try:
|
||||
ScopedSession.rollback()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
|
||||
finally:
|
||||
ScopedSession.remove()
|
||||
if ScopedSession.registry.has():
|
||||
try:
|
||||
ScopedSession.rollback()
|
||||
except Exception:
|
||||
log.exception('CommitSessionMiddleware: rollback failed after downstream error')
|
||||
finally:
|
||||
ScopedSession.remove()
|
||||
raise
|
||||
|
||||
# Nothing in this request touched the sync session: committing would
|
||||
# only instantiate one to run an empty transaction.
|
||||
if not ScopedSession.registry.has():
|
||||
return
|
||||
|
||||
# Downstream completed. Commit pending sync work.
|
||||
try:
|
||||
ScopedSession.commit()
|
||||
|
|
@ -166,9 +172,9 @@ class AuthTokenMiddleware:
|
|||
|
||||
async def send_with_timing(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
process_time = int(time.monotonic() - start_time)
|
||||
process_time = time.monotonic() - start_time
|
||||
headers = MutableHeaders(scope=message)
|
||||
headers['X-Process-Time'] = str(process_time)
|
||||
headers['X-Process-Time'] = f'{process_time:.6f}'
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_timing)
|
||||
|
|
|
|||
|
|
@ -132,14 +132,27 @@ class AuditLoggingMiddleware:
|
|||
) -> None:
|
||||
self.app = app
|
||||
self.audit_logger = AuditLogger(logger)
|
||||
self.excluded_paths = excluded_paths or []
|
||||
self.included_paths = included_paths or []
|
||||
|
||||
def normalize_paths(paths: Optional[list[str]]) -> list[str]:
|
||||
return [path for path in (path.strip().lstrip('/') for path in paths or []) if path]
|
||||
|
||||
self.excluded_paths = normalize_paths(excluded_paths)
|
||||
self.included_paths = normalize_paths(included_paths)
|
||||
self.max_body_size = max_body_size
|
||||
self.audited_methods = set(self.DEFAULT_AUDITED_METHODS)
|
||||
if audit_get_requests:
|
||||
self.audited_methods.add('GET')
|
||||
self.audit_level = audit_level
|
||||
|
||||
# Paths are fixed for the process lifetime; compile once instead of
|
||||
# per request. None means the corresponding mode has nothing to match.
|
||||
self._included_pattern = (
|
||||
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b') if self.included_paths else None
|
||||
)
|
||||
self._excluded_pattern = (
|
||||
re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b') if self.excluded_paths else None
|
||||
)
|
||||
|
||||
if self.included_paths and self.excluded_paths:
|
||||
logger.warning(
|
||||
'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. '
|
||||
|
|
@ -196,6 +209,13 @@ class AuditLoggingMiddleware:
|
|||
await self._log_audit_entry(request, context)
|
||||
|
||||
async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
|
||||
# get_current_user stashes the resolved user on the scope-backed state;
|
||||
# reuse it instead of running the full auth pipeline (JWT decode, Redis
|
||||
# revocation checks, DB fetch, last-active write) a second time.
|
||||
user = getattr(request.state, 'user', None)
|
||||
if isinstance(user, UserModel):
|
||||
return user
|
||||
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
try:
|
||||
|
|
@ -206,6 +226,12 @@ class AuditLoggingMiddleware:
|
|||
|
||||
return None
|
||||
|
||||
ALWAYS_LOG_ENDPOINTS = (
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signout',
|
||||
'/api/v1/auths/signup',
|
||||
)
|
||||
|
||||
def _should_skip_auditing(self, request: Request) -> bool:
|
||||
if AUDIT_LOG_LEVEL == 'NONE':
|
||||
return True
|
||||
|
|
@ -213,13 +239,8 @@ class AuditLoggingMiddleware:
|
|||
if request.method not in self.audited_methods:
|
||||
return True
|
||||
|
||||
ALWAYS_LOG_ENDPOINTS = {
|
||||
'/api/v1/auths/signin',
|
||||
'/api/v1/auths/signout',
|
||||
'/api/v1/auths/signup',
|
||||
}
|
||||
path = request.url.path.lower()
|
||||
for endpoint in ALWAYS_LOG_ENDPOINTS:
|
||||
for endpoint in self.ALWAYS_LOG_ENDPOINTS:
|
||||
if path.startswith(endpoint):
|
||||
return False # Do NOT skip logging for auth endpoints
|
||||
|
||||
|
|
@ -229,15 +250,11 @@ class AuditLoggingMiddleware:
|
|||
return True
|
||||
|
||||
# Whitelist mode: only log paths that match included_paths
|
||||
if self.included_paths:
|
||||
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.included_paths) + r')\b')
|
||||
if not pattern.match(request.url.path):
|
||||
return True # Skip: path not in whitelist
|
||||
return False # Do NOT skip: path is in whitelist
|
||||
if self._included_pattern:
|
||||
return not self._included_pattern.match(request.url.path)
|
||||
|
||||
# Blacklist mode: skip paths that match excluded_paths
|
||||
pattern = re.compile(r'^/api(?:/v1)?/(' + '|'.join(self.excluded_paths) + r')\b')
|
||||
if pattern.match(request.url.path):
|
||||
if self._excluded_pattern and self._excluded_pattern.match(request.url.path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -356,6 +356,8 @@ async def get_current_user(
|
|||
current_span.set_attribute('client.user.role', user.role)
|
||||
current_span.set_attribute('client.auth.type', 'api_key')
|
||||
|
||||
# Scope-backed, so outer middleware (audit) can reuse the resolved user
|
||||
request.state.user = user
|
||||
return user
|
||||
|
||||
# auth by jwt token
|
||||
|
|
@ -404,6 +406,9 @@ async def get_current_user(
|
|||
# Refresh the user's last active timestamp
|
||||
# Fire-and-forget via asyncio.create_task to avoid blocking
|
||||
asyncio.create_task(Users.update_last_active_by_id(user.id))
|
||||
|
||||
# Scope-backed, so outer middleware (audit) can reuse the resolved user
|
||||
request.state.user = user
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -179,8 +179,10 @@ async def generate_chat_completion(
|
|||
# Merge the direct connection model into server models so that
|
||||
# task functions (title, tags, etc.) can resolve a server-side
|
||||
# task model while still having the direct model available.
|
||||
# dict(...items()) is one HGETALL on a Redis-backed pool; ``{**pool}``
|
||||
# would issue HKEYS plus one HGET per model.
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
**dict(request.app.state.MODELS.items()),
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
log.debug(f'direct connection to model: {request.state.model["id"]}')
|
||||
|
|
@ -188,11 +190,12 @@ async def generate_chat_completion(
|
|||
models = request.app.state.MODELS
|
||||
|
||||
model_id = form_data['model']
|
||||
if model_id not in models:
|
||||
# Single lookup — membership check plus getitem would be two Redis
|
||||
# round trips on a Redis-backed model pool.
|
||||
model = models.get(model_id)
|
||||
if model is None:
|
||||
raise Exception('Model not found')
|
||||
|
||||
model = models[model_id]
|
||||
|
||||
if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'):
|
||||
return await generate_direct_chat_completion(request, form_data, user=user, models=models)
|
||||
else:
|
||||
|
|
|
|||
288
backend/open_webui/utils/chat_variables.py
Normal file
288
backend/open_webui/utils/chat_variables.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
CHAT_VARIABLE_KEY_RE = re.compile(r'^[a-z][a-z0-9_]*$')
|
||||
CHAT_VARIABLE_ANY_RE = re.compile(r'{{\s*chat\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}')
|
||||
USER_VARIABLE_ANY_RE = re.compile(r'{{\s*user\.variables\.([^\s|}]+)(?:\s*\|\s*([^}]*))?\s*}}')
|
||||
MAX_VARIABLE_VALUE_LENGTH = 20_000
|
||||
MAX_VARIABLES_JSON_LENGTH = 100_000
|
||||
|
||||
|
||||
class ChatVariablesError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def split_properties(value: str, delimiter: str) -> list[str]:
|
||||
result: list[str] = []
|
||||
current = ''
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
for char in value:
|
||||
if escape_next:
|
||||
current += char
|
||||
escape_next = False
|
||||
continue
|
||||
|
||||
if char == '\\':
|
||||
current += char
|
||||
escape_next = True
|
||||
continue
|
||||
|
||||
if char == '"' and not escape_next:
|
||||
in_string = not in_string
|
||||
current += char
|
||||
continue
|
||||
|
||||
if not in_string:
|
||||
if char in ('{', '['):
|
||||
depth += 1
|
||||
elif char in ('}', ']'):
|
||||
depth -= 1
|
||||
|
||||
if char == delimiter and depth == 0:
|
||||
result.append(current.strip())
|
||||
current = ''
|
||||
continue
|
||||
|
||||
current += char
|
||||
|
||||
if current.strip():
|
||||
result.append(current.strip())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_json_value(value: str) -> Any:
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
return value[1:-1]
|
||||
|
||||
if re.match(r'^[\[{]', value):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def parse_variable_definition(definition: str) -> dict[str, Any]:
|
||||
parts = split_properties(definition, ':')
|
||||
if not parts:
|
||||
return {'type': 'text'}
|
||||
|
||||
first_part, *property_parts = parts
|
||||
field_type = first_part[5:] if first_part.startswith('type=') else first_part
|
||||
field_type = field_type.strip() or 'text'
|
||||
properties: dict[str, Any] = {}
|
||||
|
||||
for part in property_parts:
|
||||
trimmed = part.strip()
|
||||
if not trimmed:
|
||||
continue
|
||||
|
||||
equals_parts = split_properties(trimmed, '=')
|
||||
if len(equals_parts) == 1:
|
||||
properties[equals_parts[0].strip()] = True
|
||||
continue
|
||||
|
||||
property_name, *value_parts = equals_parts
|
||||
properties[property_name.strip()] = parse_json_value('='.join(value_parts).strip())
|
||||
|
||||
return {'type': field_type, **properties}
|
||||
|
||||
|
||||
def _safe_field(key: str, definition: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed_keys = {
|
||||
'default',
|
||||
'label',
|
||||
'max',
|
||||
'maxlength',
|
||||
'min',
|
||||
'minlength',
|
||||
'options',
|
||||
'placeholder',
|
||||
'required',
|
||||
'step',
|
||||
'type',
|
||||
}
|
||||
field = {'key': key}
|
||||
for field_key in allowed_keys:
|
||||
if field_key in definition:
|
||||
field[field_key] = definition[field_key]
|
||||
|
||||
field.setdefault('type', 'text')
|
||||
if field.get('type') == 'select' and not isinstance(field.get('options'), list):
|
||||
field['options'] = []
|
||||
field['required'] = bool(field.get('required', False))
|
||||
|
||||
return field
|
||||
|
||||
|
||||
def get_chat_variables_schema(system_prompt: str | None) -> dict[str, list[dict[str, Any]]] | None:
|
||||
if not system_prompt:
|
||||
return None
|
||||
|
||||
try:
|
||||
fields_by_key = collect_chat_variable_fields(system_prompt)
|
||||
except ChatVariablesError:
|
||||
fields_by_key = {}
|
||||
|
||||
if not fields_by_key:
|
||||
return None
|
||||
|
||||
return {'fields': list(fields_by_key.values())}
|
||||
|
||||
|
||||
def collect_chat_variable_fields(system_prompt: str | None) -> dict[str, dict[str, Any]]:
|
||||
fields_by_key: dict[str, dict[str, Any]] = {}
|
||||
if not system_prompt:
|
||||
return fields_by_key
|
||||
|
||||
typed_fields_by_key: dict[str, dict[str, Any]] = {}
|
||||
for match in CHAT_VARIABLE_ANY_RE.finditer(system_prompt):
|
||||
key = match.group(1).strip()
|
||||
definition = match.group(2)
|
||||
if not CHAT_VARIABLE_KEY_RE.match(key):
|
||||
raise ChatVariablesError(f'Invalid chat variable key: {key}')
|
||||
|
||||
if definition is None or not definition.strip():
|
||||
fields_by_key.setdefault(key, _safe_field(key, {'type': 'text'}))
|
||||
continue
|
||||
|
||||
field = _safe_field(key, parse_variable_definition(definition.strip()))
|
||||
|
||||
if field.get('type') == 'select' and not field.get('options'):
|
||||
raise ChatVariablesError(f'Chat variable {key} select needs options.')
|
||||
previous = typed_fields_by_key.get(key)
|
||||
if previous and previous != field:
|
||||
raise ChatVariablesError(f'Chat variable {key} has conflicting definitions.')
|
||||
typed_fields_by_key[key] = field
|
||||
fields_by_key[key] = field
|
||||
|
||||
return fields_by_key
|
||||
|
||||
|
||||
def normalize_chat_variables(variables: Any) -> dict[str, Any]:
|
||||
if not isinstance(variables, dict):
|
||||
return {}
|
||||
return variables
|
||||
|
||||
|
||||
def normalize_user_variables(variables: Any) -> dict[str, str]:
|
||||
if not isinstance(variables, dict):
|
||||
return {}
|
||||
return {key: value for key, value in variables.items() if isinstance(key, str) and isinstance(value, str)}
|
||||
|
||||
|
||||
def validate_user_variables(variables: Any) -> dict[str, str]:
|
||||
if not isinstance(variables, dict):
|
||||
raise ChatVariablesError('User variables must be an object.')
|
||||
|
||||
try:
|
||||
if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
raise ChatVariablesError('User variables are too large.')
|
||||
except TypeError:
|
||||
raise ChatVariablesError('User variables must be JSON serializable.')
|
||||
|
||||
validated: dict[str, str] = {}
|
||||
for key, value in variables.items():
|
||||
if not isinstance(key, str) or not CHAT_VARIABLE_KEY_RE.match(key):
|
||||
raise ChatVariablesError(f'Invalid user variable key: {key}')
|
||||
if not isinstance(value, str):
|
||||
raise ChatVariablesError(f'User variable must be a string: {key}')
|
||||
value = value.replace('\r\n', '\n')
|
||||
if len(value) > MAX_VARIABLE_VALUE_LENGTH:
|
||||
raise ChatVariablesError(f'User variable is too long: {key}')
|
||||
validated[key] = value
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def validate_chat_variables(
|
||||
system_prompt: str | None,
|
||||
variables: Any,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
field_map = collect_chat_variable_fields(system_prompt)
|
||||
variables = normalize_chat_variables(variables)
|
||||
|
||||
try:
|
||||
if len(json.dumps(variables)) > MAX_VARIABLES_JSON_LENGTH:
|
||||
raise ChatVariablesError('Chat variables are too large.')
|
||||
except TypeError:
|
||||
raise ChatVariablesError('Chat variables must be JSON serializable.')
|
||||
|
||||
validated: dict[str, Any] = {}
|
||||
for key, field in field_map.items():
|
||||
has_value = key in variables and variables[key] not in (None, '')
|
||||
value = variables.get(key)
|
||||
|
||||
if not has_value:
|
||||
if field.get('default') not in (None, ''):
|
||||
value = field.get('default')
|
||||
has_value = True
|
||||
elif required and field.get('required'):
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Missing required chat variable: {label}')
|
||||
else:
|
||||
value = ''
|
||||
|
||||
if field.get('type') == 'select':
|
||||
options = field.get('options') or []
|
||||
if has_value and value not in options:
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Invalid value for chat variable: {label}')
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.replace('\r\n', '\n')
|
||||
if len(value) > MAX_VARIABLE_VALUE_LENGTH:
|
||||
label = field.get('label') or key
|
||||
raise ChatVariablesError(f'Chat variable is too long: {label}')
|
||||
|
||||
validated[key] = value
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def render_chat_variables(
|
||||
system_prompt: str | None,
|
||||
variables: Any,
|
||||
*,
|
||||
required: bool = True,
|
||||
) -> str | None:
|
||||
if not system_prompt:
|
||||
return system_prompt
|
||||
|
||||
try:
|
||||
validated = validate_chat_variables(system_prompt, variables, required=required)
|
||||
except ChatVariablesError:
|
||||
validated = {}
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
key = match.group(1).strip()
|
||||
value = validated.get(key, '')
|
||||
return '' if value is None else str(value)
|
||||
|
||||
return CHAT_VARIABLE_ANY_RE.sub(replace, system_prompt)
|
||||
|
||||
|
||||
def render_user_variables(system_prompt: str | None, variables: Any) -> str | None:
|
||||
if not system_prompt:
|
||||
return system_prompt
|
||||
|
||||
variables = normalize_user_variables(variables)
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
key = match.group(1).strip()
|
||||
if not CHAT_VARIABLE_KEY_RE.match(key):
|
||||
return ''
|
||||
return variables.get(key, '')
|
||||
|
||||
return USER_VARIABLE_ANY_RE.sub(replace, system_prompt)
|
||||
|
|
@ -56,7 +56,7 @@ async def compact_messages_for_request(
|
|||
if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3:
|
||||
return messages, previous_summary, False
|
||||
|
||||
boundary = _find_compaction_boundary(messages)
|
||||
boundary = _find_compaction_boundary(messages, config['retention_percentage'])
|
||||
compacted_messages = messages[:boundary]
|
||||
recent_messages = messages[boundary:]
|
||||
if not compacted_messages or not recent_messages:
|
||||
|
|
@ -196,6 +196,7 @@ async def _load_config() -> dict:
|
|||
'chat.context_compaction.enable',
|
||||
'chat.context_compaction.token_threshold',
|
||||
'chat.context_compaction.token_cap',
|
||||
'chat.context_compaction.retention_percentage',
|
||||
'chat.context_compaction.prompt_template',
|
||||
)
|
||||
token_threshold = _parse_positive_int(values.get('chat.context_compaction.token_threshold')) or 80000
|
||||
|
|
@ -203,6 +204,9 @@ async def _load_config() -> dict:
|
|||
'enable': bool(values.get('chat.context_compaction.enable', False)),
|
||||
'token_threshold': token_threshold,
|
||||
'token_cap': _parse_positive_int(values.get('chat.context_compaction.token_cap')) or token_threshold,
|
||||
'retention_percentage': _clamp_retention_percentage(
|
||||
values.get('chat.context_compaction.retention_percentage')
|
||||
),
|
||||
'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '',
|
||||
}
|
||||
|
||||
|
|
@ -215,6 +219,14 @@ def _parse_positive_int(value: Any) -> int | None:
|
|||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _clamp_retention_percentage(value: Any) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = 40
|
||||
return min(50, max(10, parsed))
|
||||
|
||||
|
||||
def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: dict) -> int:
|
||||
configured_threshold = _parse_positive_int((metadata.get('params') or {}).get('compact_token_threshold'))
|
||||
return min(configured_threshold or global_threshold, global_cap)
|
||||
|
|
@ -297,8 +309,9 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary:
|
|||
return estimated > threshold
|
||||
|
||||
|
||||
def _find_compaction_boundary(messages: list[dict]) -> int:
|
||||
keep_count = max(2, len(messages) * 2 // 5)
|
||||
def _find_compaction_boundary(messages: list[dict], retention_percentage: int = 40) -> int:
|
||||
retention_percentage = _clamp_retention_percentage(retention_percentage)
|
||||
keep_count = max(2, len(messages) * retention_percentage // 100)
|
||||
target = max(1, len(messages) - keep_count)
|
||||
boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:]
|
||||
return next((idx for idx in reversed(boundaries) if idx <= target), 0)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import re
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiofiles
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
|
|
@ -75,7 +76,7 @@ async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
|
|||
# file-ID resolver which enforces ownership/access checks.
|
||||
return await get_image_base64_from_file_id(url, user=user)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -200,15 +201,15 @@ async def get_image_base64_from_file_id(id: str, user=None) -> Optional[str]:
|
|||
|
||||
# Check if the file already exists in the cache
|
||||
if file_path.is_file():
|
||||
with open(file_path, 'rb') as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
|
||||
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
|
||||
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
|
||||
if not content_type:
|
||||
return None
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
async with aiofiles.open(file_path, 'rb') as image_file:
|
||||
encoded_string = base64.b64encode(await image_file.read()).decode('utf-8')
|
||||
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
|
||||
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
|
||||
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
|
||||
if not content_type:
|
||||
return None
|
||||
return f'data:{content_type};base64,{encoded_string}'
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ from open_webui.routers.images import (
|
|||
image_generations,
|
||||
)
|
||||
from open_webui.routers.pipelines import (
|
||||
get_sorted_filters,
|
||||
process_pipeline_inlet_filter,
|
||||
process_pipeline_outlet_filter,
|
||||
)
|
||||
|
|
@ -106,6 +107,7 @@ from open_webui.utils.misc import (
|
|||
get_last_user_message,
|
||||
get_last_user_message_item,
|
||||
get_message_list,
|
||||
get_output_text,
|
||||
get_system_message,
|
||||
is_string_allowed,
|
||||
merge_system_messages,
|
||||
|
|
@ -136,17 +138,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _last_output_text(output: list | None) -> str:
|
||||
for item in reversed(output or []):
|
||||
if item.get('type') != 'message':
|
||||
continue
|
||||
parts = item.get('content') or []
|
||||
text = ''.join(str(part.get('text') or '') for part in parts if part.get('type') == 'output_text')
|
||||
if text:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
async def publish_chat_finished_event(
|
||||
request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None
|
||||
):
|
||||
|
|
@ -154,7 +145,7 @@ async def publish_chat_finished_event(
|
|||
if getattr(request.state, 'internal', False) is True or not chat_id or chat_id.startswith(('channel:', 'local:')):
|
||||
return
|
||||
|
||||
content = content or _last_output_text(output)
|
||||
content = content or get_output_text(output)
|
||||
webui_url = await Config.get('webui.url')
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -2157,23 +2148,25 @@ def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
|
|||
return ids
|
||||
|
||||
|
||||
SKILL_MENTION_STRIP_RE = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
|
||||
|
||||
|
||||
def strip_skill_mentions(messages: list[dict]) -> None:
|
||||
"""Replace <$skillId|label> and </skillId|label> mention tags with the label in-place."""
|
||||
strip_re = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
|
||||
|
||||
def label(match):
|
||||
return match.group(1) or match.group(2) or ''
|
||||
|
||||
for message in messages:
|
||||
content = message.get('content')
|
||||
if isinstance(content, str) and strip_re.search(content):
|
||||
message['content'] = strip_re.sub(label, content).strip()
|
||||
if isinstance(content, str) and SKILL_MENTION_STRIP_RE.search(content):
|
||||
message['content'] = SKILL_MENTION_STRIP_RE.sub(label, content).strip()
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'text':
|
||||
text = part.get('text', '')
|
||||
if strip_re.search(text):
|
||||
part['text'] = strip_re.sub(label, text).strip()
|
||||
if SKILL_MENTION_STRIP_RE.search(text):
|
||||
part['text'] = SKILL_MENTION_STRIP_RE.sub(label, text).strip()
|
||||
|
||||
|
||||
async def connect_mcp_server(
|
||||
|
|
@ -2572,7 +2565,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
|
||||
# Mentioned skills get full content; selected/default skills can be loaded through view_skill.
|
||||
mentioned_skill_ids = extract_skill_ids_from_messages(form_data.get('messages', []))
|
||||
skill_ids = (
|
||||
skill_ids = sorted(
|
||||
set(form_data.pop('skill_ids', None) or [])
|
||||
| set(model.get('info', {}).get('meta', {}).get('skillIds', []))
|
||||
| mentioned_skill_ids
|
||||
|
|
@ -2675,7 +2668,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
{
|
||||
'model_id': form_data.get('model'),
|
||||
'tool_ids': tool_ids,
|
||||
'skill_ids': list(skill_ids),
|
||||
'skill_ids': skill_ids,
|
||||
'terminal_id': terminal_id,
|
||||
'files': files,
|
||||
'features': features,
|
||||
|
|
@ -3395,7 +3388,7 @@ async def outlet_filter_handler(ctx):
|
|||
message_list = [
|
||||
{
|
||||
'role': m.get('role'),
|
||||
'content': m.get('content', ''),
|
||||
'content': m.get('content') or get_output_text(m.get('output')),
|
||||
}
|
||||
for m in form_messages
|
||||
]
|
||||
|
|
@ -3428,7 +3421,7 @@ async def outlet_filter_handler(ctx):
|
|||
{
|
||||
'id': m.get('id'),
|
||||
'role': m.get('role'),
|
||||
'content': m.get('content', ''),
|
||||
'content': m.get('content') or get_output_text(m.get('output')),
|
||||
'info': m.get('info'),
|
||||
'timestamp': m.get('timestamp'),
|
||||
**({'output': m['output']} if m.get('output') else {}),
|
||||
|
|
@ -3480,17 +3473,21 @@ async def outlet_filter_handler(ctx):
|
|||
outlet_message_id = message.get('id')
|
||||
if outlet_message_id and outlet_message_id in messages_map:
|
||||
original_message = messages_map[outlet_message_id]
|
||||
content_changed = original_message.get('content') != message.get('content')
|
||||
original_content = original_message.get('content') or get_output_text(
|
||||
original_message.get('output')
|
||||
)
|
||||
message_content = message.get('content') or get_output_text(message.get('output'))
|
||||
content_changed = original_content != message_content
|
||||
output_changed = message.get('output') and message.get('output') != original_message.get(
|
||||
'output'
|
||||
)
|
||||
if content_changed or output_changed:
|
||||
message_update = {
|
||||
'originalContent': original_message.get('content'),
|
||||
'originalContent': original_content,
|
||||
**({'output': message['output']} if output_changed else {}),
|
||||
}
|
||||
if content_changed:
|
||||
message_update['content'] = message.get('content', '')
|
||||
message_update['content'] = message_content or ''
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
outlet_message_id,
|
||||
|
|
@ -3972,9 +3969,10 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
except Exception as e:
|
||||
pass
|
||||
|
||||
content = (
|
||||
initial_content = (
|
||||
message.get('content', '') if message else last_assistant_message if last_assistant_message else ''
|
||||
)
|
||||
content_parts = [initial_content] if initial_content else []
|
||||
|
||||
# Initialize output: use existing from message if continuing, else create new
|
||||
existing_output = message.get('output') if message else None
|
||||
|
|
@ -3982,14 +3980,14 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
output = existing_output
|
||||
else:
|
||||
# Only create an initial message item if there is content to initialize with
|
||||
if content:
|
||||
if initial_content:
|
||||
output = [
|
||||
{
|
||||
'type': 'message',
|
||||
'id': output_id('msg'),
|
||||
'status': 'in_progress',
|
||||
'role': 'assistant',
|
||||
'content': [{'type': 'output_text', 'text': content}],
|
||||
'content': [{'type': 'output_text', 'text': initial_content}],
|
||||
}
|
||||
]
|
||||
else:
|
||||
|
|
@ -4051,7 +4049,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
|
||||
async def stream_body_handler(response, form_data):
|
||||
nonlocal content
|
||||
nonlocal content_parts
|
||||
nonlocal usage
|
||||
nonlocal output
|
||||
nonlocal prior_output
|
||||
|
|
@ -4116,7 +4114,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
raw_error = raw_obj.get('error') if isinstance(raw_obj, dict) else None
|
||||
if raw_error:
|
||||
try:
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
|
|
@ -4502,12 +4500,8 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
user,
|
||||
)
|
||||
|
||||
if isinstance(value, str):
|
||||
# In-place append — avoids copying the full
|
||||
# accumulated response on every chunk.
|
||||
content += value
|
||||
else:
|
||||
content = f'{content}{value}'
|
||||
# closure-cell str += recopies per chunk; append + join once at read is O(n)
|
||||
content_parts.append(value if isinstance(value, str) else f'{value}')
|
||||
|
||||
# Check if we're inside a tag-based block
|
||||
# (reasoning, code_interpreter, or solution).
|
||||
|
|
@ -4789,12 +4783,12 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
params = {}
|
||||
if tool_args and tool_args.strip():
|
||||
try:
|
||||
params = ast.literal_eval(tool_args)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
params = json.loads(tool_args)
|
||||
except Exception:
|
||||
try:
|
||||
params = json.loads(tool_args)
|
||||
except Exception:
|
||||
params = ast.literal_eval(tool_args)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
return None
|
||||
tool_call.setdefault('function', {})['arguments'] = json.dumps(params)
|
||||
return params
|
||||
|
|
@ -5372,7 +5366,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
{'done': True},
|
||||
)
|
||||
|
||||
await publish_chat_finished_event(request, user, metadata, title, content, output)
|
||||
await publish_chat_finished_event(request, user, metadata, title, ''.join(content_parts), output)
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
|
|
@ -5438,6 +5432,16 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
return f'data: {item}\n\n'
|
||||
|
||||
assistant_message = {}
|
||||
has_api_outlet_filters = ENABLE_API_OUTLET_FILTERS and bool(filter_functions)
|
||||
if ENABLE_API_OUTLET_FILTERS and not has_api_outlet_filters:
|
||||
try:
|
||||
model_id = model.get('id') if isinstance(model, dict) else model
|
||||
has_api_outlet_filters = bool(
|
||||
(isinstance(model, dict) and 'pipeline' in model)
|
||||
or get_sorted_filters(model_id, request.app.state.MODELS)
|
||||
)
|
||||
except Exception:
|
||||
has_api_outlet_filters = True
|
||||
|
||||
for event in events:
|
||||
event, _ = await process_filter_functions(
|
||||
|
|
@ -5461,11 +5465,11 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
|
||||
if data:
|
||||
if ENABLE_API_OUTLET_FILTERS:
|
||||
if has_api_outlet_filters:
|
||||
update_assistant_message_from_stream(assistant_message, data)
|
||||
yield data
|
||||
|
||||
if ENABLE_API_OUTLET_FILTERS and assistant_message:
|
||||
if has_api_outlet_filters and assistant_message:
|
||||
ctx['assistant_message'] = assistant_message
|
||||
await outlet_filter_handler(ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import mimeparse
|
|||
from open_webui.env import CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
SURROGATE_RE = re.compile('[\ud800-\udfff]')
|
||||
|
||||
|
||||
def deep_update(d, u):
|
||||
|
|
@ -28,18 +29,26 @@ def deep_update(d, u):
|
|||
return d
|
||||
|
||||
|
||||
def _strip_filter_entry(entry):
|
||||
# Compose list-form env syntax passes surrounding quotes through verbatim
|
||||
return (entry or '').strip().strip('"\'').strip()
|
||||
|
||||
|
||||
def get_allow_block_lists(filter_list):
|
||||
allow_list = []
|
||||
block_list = []
|
||||
|
||||
if filter_list:
|
||||
for d in filter_list:
|
||||
if d.startswith('!'):
|
||||
# Domains starting with "!" → blocked
|
||||
block_list.append(d[1:].strip())
|
||||
else:
|
||||
# Domains starting without "!" → allowed
|
||||
allow_list.append(d.strip())
|
||||
for raw_entry in filter_list or []:
|
||||
entry = _strip_filter_entry(raw_entry)
|
||||
is_blocked = entry.startswith('!')
|
||||
if is_blocked:
|
||||
entry = _strip_filter_entry(entry[1:])
|
||||
if not entry:
|
||||
continue
|
||||
if is_blocked:
|
||||
block_list.append(entry)
|
||||
else:
|
||||
allow_list.append(entry)
|
||||
|
||||
return allow_list, block_list
|
||||
|
||||
|
|
@ -159,13 +168,40 @@ def get_last_user_message_item(messages: list[dict]) -> dict | None:
|
|||
|
||||
|
||||
def get_content_from_message(message: dict) -> str | None:
|
||||
if isinstance(message.get('content'), list):
|
||||
for item in message['content']:
|
||||
if item['type'] == 'text':
|
||||
return item['text']
|
||||
else:
|
||||
return message.get('content')
|
||||
return None
|
||||
content = message.get('content')
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get('type') == 'text':
|
||||
return item.get('text')
|
||||
elif content:
|
||||
return content
|
||||
|
||||
output_text = get_output_text(message.get('output'))
|
||||
return output_text or (content if isinstance(content, str) else None)
|
||||
|
||||
|
||||
def get_output_text(output: list | None) -> str:
|
||||
if not isinstance(output, list):
|
||||
return ''
|
||||
|
||||
texts = []
|
||||
for item in output:
|
||||
if not isinstance(item, dict) or item.get('type') != 'message':
|
||||
continue
|
||||
|
||||
parts = item.get('content') or []
|
||||
if not isinstance(parts, list):
|
||||
continue
|
||||
|
||||
text = ''.join(
|
||||
str(part.get('text'))
|
||||
for part in parts
|
||||
if isinstance(part, dict) and part.get('text') is not None
|
||||
)
|
||||
if text.strip():
|
||||
texts.append(text)
|
||||
|
||||
return '\n'.join(texts)
|
||||
|
||||
|
||||
def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
|
||||
|
|
@ -212,7 +248,7 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
|
|||
|
||||
# All tool_calls were orphans — keep the message only if it
|
||||
# carries meaningful text or reasoning content.
|
||||
content = message.get('content', '')
|
||||
content = get_content_from_message(message) or ''
|
||||
has_meaningful_content = content.strip() if isinstance(content, str) else content
|
||||
if has_meaningful_content or message.get('reasoning_content'):
|
||||
reconciled_messages.append({key: value for key, value in message.items() if key != 'tool_calls'})
|
||||
|
|
@ -715,18 +751,10 @@ def sanitize_text_for_db(text: str) -> str:
|
|||
"""Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage."""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
# Fast path: skip work when there are no null bytes (the common case)
|
||||
if '\x00' not in text:
|
||||
# Fast path: skip work when there are no null bytes or surrogate code points.
|
||||
if '\x00' not in text and not SURROGATE_RE.search(text):
|
||||
return text
|
||||
# Remove null bytes
|
||||
text = text.replace('\x00', '').replace('\u0000', '')
|
||||
# Remove invalid UTF-8 surrogate characters that can cause encoding errors
|
||||
# This handles cases where binary data or encoding issues introduced surrogates
|
||||
try:
|
||||
text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore')
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
pass
|
||||
return text
|
||||
return SURROGATE_RE.sub('', text.replace('\x00', ''))
|
||||
|
||||
|
||||
def _strip_null_bytes_deep(obj):
|
||||
|
|
@ -734,7 +762,10 @@ def _strip_null_bytes_deep(obj):
|
|||
if isinstance(obj, str):
|
||||
return sanitize_text_for_db(obj)
|
||||
elif isinstance(obj, dict):
|
||||
return {k: _strip_null_bytes_deep(v) for k, v in obj.items()}
|
||||
cleaned = {}
|
||||
for k, v in obj.items():
|
||||
cleaned[sanitize_text_for_db(k) if isinstance(k, str) else k] = _strip_null_bytes_deep(v)
|
||||
return cleaned
|
||||
elif isinstance(obj, list):
|
||||
return [_strip_null_bytes_deep(v) for v in obj]
|
||||
return obj
|
||||
|
|
@ -744,19 +775,21 @@ def sanitize_data_for_db(obj):
|
|||
"""Recursively sanitize all strings in a data structure for database storage.
|
||||
|
||||
Performs a fast pre-check: serializes the structure once and scans for
|
||||
null bytes. If none are found (the overwhelmingly common case), the
|
||||
null bytes or invalid UTF-8 surrogates. If none are found, the
|
||||
original object is returned immediately, skipping the expensive
|
||||
recursive walk.
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
return sanitize_text_for_db(obj)
|
||||
# Fast path: check for null bytes in the serialized form.
|
||||
# Fast path: check for null bytes and surrogate code points in the serialized form.
|
||||
# json.dumps is implemented in C and much faster than a Python-level
|
||||
# recursive walk over every leaf string.
|
||||
try:
|
||||
if '\\u0000' not in json.dumps(obj, ensure_ascii=False):
|
||||
serialized = json.dumps(obj, ensure_ascii=False)
|
||||
if '\\u0000' not in serialized:
|
||||
serialized.encode('utf-8')
|
||||
return obj
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, UnicodeEncodeError):
|
||||
pass
|
||||
return _strip_null_bytes_deep(obj)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from open_webui.models.config import Config
|
|||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.utils.chat_variables import get_chat_variables_schema
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.routers import ollama, openai
|
||||
from open_webui.socket.utils import RedisDict
|
||||
|
|
@ -129,13 +130,13 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
# One query per type: the global sets are subsets of the active sets, so
|
||||
# deriving them from the same rows halves the function-table queries.
|
||||
if ENABLE_PLUGINS:
|
||||
active_actions = await Functions.get_functions_by_type('action', active_only=True)
|
||||
global_action_ids = {function.id for function in active_actions if function.is_global}
|
||||
enabled_action_ids = {function.id for function in active_actions}
|
||||
active_actions = await Functions.get_active_function_ids_by_type('action')
|
||||
global_action_ids = {function_id for function_id, is_global in active_actions if is_global}
|
||||
enabled_action_ids = {function_id for function_id, _ in active_actions}
|
||||
|
||||
active_filters = await Functions.get_functions_by_type('filter', active_only=True)
|
||||
global_filter_ids = {function.id for function in active_filters if function.is_global}
|
||||
enabled_filter_ids = {function.id for function in active_filters}
|
||||
active_filters = await Functions.get_active_function_ids_by_type('filter')
|
||||
global_filter_ids = {function_id for function_id, is_global in active_filters if is_global}
|
||||
enabled_filter_ids = {function_id for function_id, _ in active_filters}
|
||||
else:
|
||||
global_action_ids = set()
|
||||
enabled_action_ids = set()
|
||||
|
|
@ -162,6 +163,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
if custom_model.is_active:
|
||||
model['name'] = custom_model.name
|
||||
model['info'] = custom_model.model_dump()
|
||||
schema = get_chat_variables_schema(custom_model.params.model_dump().get('system'))
|
||||
if schema:
|
||||
model['info'].setdefault('meta', {})['chat_variables_schema'] = schema
|
||||
|
||||
action_ids = []
|
||||
filter_ids = []
|
||||
|
|
@ -211,6 +215,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
}
|
||||
|
||||
info = custom_model.model_dump()
|
||||
schema = get_chat_variables_schema(custom_model.params.model_dump().get('system'))
|
||||
if schema:
|
||||
info.setdefault('meta', {})['chat_variables_schema'] = schema
|
||||
if 'params' in info:
|
||||
# Remove params to avoid exposing sensitive info
|
||||
del info['params']
|
||||
|
|
@ -328,16 +335,27 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
all_function_valves = await Functions.get_function_valves_by_ids(list(all_function_ids))
|
||||
functions_cache = get_functions_cache(request)
|
||||
|
||||
# Global actions and filters appear in every model, so priorities and item
|
||||
# lists are memoized across the loop instead of rebuilt per model.
|
||||
action_priorities = {}
|
||||
|
||||
def get_action_priority(action_id):
|
||||
if action_id in action_priorities:
|
||||
return action_priorities[action_id]
|
||||
priority = 0
|
||||
try:
|
||||
function_module = functions_cache.get(action_id)
|
||||
if function_module and hasattr(function_module, 'Valves'):
|
||||
valves_db = all_function_valves.get(action_id)
|
||||
valves = function_module.Valves(**(valves_db if valves_db else {}))
|
||||
return getattr(valves, 'priority', 0)
|
||||
priority = getattr(valves, 'priority', 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
priority = 0
|
||||
action_priorities[action_id] = priority
|
||||
return priority
|
||||
|
||||
action_items_by_id = {}
|
||||
filter_items_by_id = {}
|
||||
|
||||
for model in models:
|
||||
action_ids = [
|
||||
|
|
@ -355,30 +373,45 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
|
||||
model['actions'] = []
|
||||
for action_id in action_ids:
|
||||
action_function = functions_by_id.get(action_id)
|
||||
if action_function is None:
|
||||
log.info(f'Action not found: {action_id}')
|
||||
continue
|
||||
items = action_items_by_id.get(action_id)
|
||||
if items is None:
|
||||
action_function = functions_by_id.get(action_id)
|
||||
if action_function is None:
|
||||
log.info(f'Action not found: {action_id}')
|
||||
action_items_by_id[action_id] = []
|
||||
continue
|
||||
|
||||
function_module = functions_cache.get(action_id)
|
||||
if function_module is None:
|
||||
log.info(f'Failed to load action module: {action_id}')
|
||||
continue
|
||||
model['actions'].extend(get_action_items_from_module(action_function, function_module))
|
||||
function_module = functions_cache.get(action_id)
|
||||
if function_module is None:
|
||||
log.info(f'Failed to load action module: {action_id}')
|
||||
action_items_by_id[action_id] = []
|
||||
continue
|
||||
items = get_action_items_from_module(action_function, function_module)
|
||||
action_items_by_id[action_id] = items
|
||||
# Shallow copies keep per-model item dicts independent, as before
|
||||
model['actions'].extend({**item} for item in items)
|
||||
|
||||
model['filters'] = []
|
||||
for filter_id in filter_ids:
|
||||
filter_function = functions_by_id.get(filter_id)
|
||||
if filter_function is None:
|
||||
log.info(f'Filter not found: {filter_id}')
|
||||
continue
|
||||
items = filter_items_by_id.get(filter_id)
|
||||
if items is None:
|
||||
filter_function = functions_by_id.get(filter_id)
|
||||
if filter_function is None:
|
||||
log.info(f'Filter not found: {filter_id}')
|
||||
filter_items_by_id[filter_id] = []
|
||||
continue
|
||||
|
||||
function_module = functions_cache.get(filter_id)
|
||||
if function_module is None:
|
||||
log.info(f'Failed to load filter module: {filter_id}')
|
||||
continue
|
||||
if getattr(function_module, 'toggle', None):
|
||||
model['filters'].extend(get_filter_items_from_module(filter_function, function_module))
|
||||
function_module = functions_cache.get(filter_id)
|
||||
if function_module is None:
|
||||
log.info(f'Failed to load filter module: {filter_id}')
|
||||
filter_items_by_id[filter_id] = []
|
||||
continue
|
||||
if getattr(function_module, 'toggle', None):
|
||||
items = get_filter_items_from_module(filter_function, function_module)
|
||||
else:
|
||||
items = []
|
||||
filter_items_by_id[filter_id] = items
|
||||
model['filters'].extend({**item} for item in items)
|
||||
|
||||
log.debug(f'get_all_models() returned {len(models)} models')
|
||||
|
||||
|
|
@ -433,7 +466,9 @@ async def check_model_access(user, model, model_info=None, db=None):
|
|||
raise Exception('Model not found')
|
||||
|
||||
# Enforce access on chained base models
|
||||
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids, db=db):
|
||||
if not await has_base_model_access(
|
||||
user.id, model_info, user_role=user.role, user_group_ids=user_group_ids, db=db
|
||||
):
|
||||
raise Exception('Model not found')
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ from typing import Literal, Optional
|
|||
|
||||
import aiohttp
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
from authlib.jose.errors import BadSignatureError
|
||||
from authlib.oauth2.rfc6749.errors import OAuth2Error
|
||||
from authlib.oidc.core import UserInfo
|
||||
from cryptography.fernet import Fernet
|
||||
|
|
@ -24,6 +23,7 @@ from fastapi import (
|
|||
HTTPException,
|
||||
status,
|
||||
)
|
||||
from joserfc.errors import BadSignatureError
|
||||
from joserfc.jws import JWSRegistry
|
||||
from joserfc.registry import HeaderParameter
|
||||
from mcp.shared.auth import (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from open_webui.utils.misc import (
|
|||
deep_update,
|
||||
replace_system_message_content,
|
||||
)
|
||||
from open_webui.utils.chat_variables import render_chat_variables, render_user_variables
|
||||
from open_webui.utils.task import prompt_template, prompt_variables_template
|
||||
|
||||
|
||||
|
|
@ -18,6 +19,15 @@ async def resolve_system_prompt(
|
|||
if not system:
|
||||
return ''
|
||||
|
||||
if metadata:
|
||||
system = render_chat_variables(
|
||||
system,
|
||||
metadata.get('chat_variables', {}),
|
||||
required=False,
|
||||
)
|
||||
|
||||
system = render_user_variables(system, getattr(user, 'variables', {}) if user else {})
|
||||
|
||||
# Metadata (WebUI Usage)
|
||||
if metadata:
|
||||
variables = metadata.get('variables', {})
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ async def load_tool_module_by_id(tool_id, content=None):
|
|||
if not ENABLE_PLUGINS:
|
||||
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
|
||||
frontmatter = None
|
||||
if content is None:
|
||||
tool = await Tools.get_tool_by_id(tool_id)
|
||||
if not tool:
|
||||
|
|
@ -238,7 +239,8 @@ async def load_tool_module_by_id(tool_id, content=None):
|
|||
|
||||
# Executing the modified content in the created module's namespace
|
||||
exec(content, module.__dict__)
|
||||
frontmatter = extract_frontmatter(content)
|
||||
if frontmatter is None:
|
||||
frontmatter = extract_frontmatter(content)
|
||||
log.info(f'Loaded module: {module.__name__}')
|
||||
|
||||
# Create and return the object if the class 'Tools' is found in the module
|
||||
|
|
@ -258,6 +260,7 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non
|
|||
if not ENABLE_PLUGINS:
|
||||
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
|
||||
frontmatter = None
|
||||
if content is None:
|
||||
function = await Functions.get_function_by_id(function_id)
|
||||
if not function:
|
||||
|
|
@ -286,7 +289,8 @@ async def load_function_module_by_id(function_id: str, content: str | None = Non
|
|||
|
||||
# Execute the modified content in the created module's namespace
|
||||
exec(content, module.__dict__)
|
||||
frontmatter = extract_frontmatter(content)
|
||||
if frontmatter is None:
|
||||
frontmatter = extract_frontmatter(content)
|
||||
log.info(f'Loaded module: {module.__name__}')
|
||||
|
||||
# Create appropriate object based on available class type in the module
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from typing import Any
|
|||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
import redis as _redis_sync
|
||||
|
||||
from open_webui.env import (
|
||||
REDIS_CLUSTER,
|
||||
REDIS_HEALTH_CHECK_INTERVAL,
|
||||
|
|
@ -33,6 +32,7 @@ _ACCEPTED_SCHEMES = frozenset({'redis', 'rediss'})
|
|||
_SENTINEL_RETRYABLE = (
|
||||
_redis_sync.exceptions.ConnectionError,
|
||||
_redis_sync.exceptions.ReadOnlyError,
|
||||
_redis_sync.exceptions.TimeoutError,
|
||||
)
|
||||
_FACTORY_METHODS = frozenset({'pipeline', 'pubsub', 'monitor', 'client', 'transaction'})
|
||||
_CONNECTION_POOL: dict[tuple, Any] = {}
|
||||
|
|
@ -126,10 +126,11 @@ class SentinelRedisProxy:
|
|||
self._sentinel = sentinel
|
||||
self._service_name = service_name
|
||||
self._async_mode = async_mode
|
||||
self._master: Any | None = None
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Proxy attribute access with automatic Sentinel failover retry."""
|
||||
current_master = self._sentinel.master_for(self._service_name)
|
||||
current_master = self._resolve_master()
|
||||
original = getattr(current_master, name)
|
||||
|
||||
# Non-callable or factory attributes pass through without wrapping.
|
||||
|
|
@ -143,7 +144,12 @@ class SentinelRedisProxy:
|
|||
|
||||
def _resolve_master(self) -> Any:
|
||||
"""Ask Sentinel for the current master connection."""
|
||||
return self._sentinel.master_for(self._service_name)
|
||||
if self._master is None:
|
||||
self._master = self._sentinel.master_for(self._service_name)
|
||||
return self._master
|
||||
|
||||
def _clear_master(self) -> None:
|
||||
self._master = None
|
||||
|
||||
def _should_retry(self, attempt: int) -> bool:
|
||||
return attempt < REDIS_SENTINEL_MAX_RETRY_COUNT - 1
|
||||
|
|
@ -184,6 +190,7 @@ class SentinelRedisProxy:
|
|||
except _SENTINEL_RETRYABLE as exc:
|
||||
if proxy._should_retry(attempt):
|
||||
proxy._log_retry(exc, attempt)
|
||||
proxy._clear_master()
|
||||
if REDIS_RECONNECT_DELAY:
|
||||
await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000)
|
||||
continue
|
||||
|
|
@ -208,6 +215,7 @@ class SentinelRedisProxy:
|
|||
except _SENTINEL_RETRYABLE as exc:
|
||||
if proxy._should_retry(attempt):
|
||||
proxy._log_retry(exc, attempt)
|
||||
proxy._clear_master()
|
||||
if REDIS_RECONNECT_DELAY:
|
||||
await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000)
|
||||
continue
|
||||
|
|
@ -229,6 +237,7 @@ class SentinelRedisProxy:
|
|||
except _SENTINEL_RETRYABLE as exc:
|
||||
if proxy._should_retry(attempt):
|
||||
proxy._log_retry(exc, attempt)
|
||||
proxy._clear_master()
|
||||
if REDIS_RECONNECT_DELAY:
|
||||
time.sleep(REDIS_RECONNECT_DELAY / 1000)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
fastapi==0.136.3
|
||||
uvicorn[standard]==0.41.0
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.27
|
||||
python-multipart==0.0.32
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.16.2
|
||||
|
|
@ -14,6 +14,7 @@ bcrypt==5.0.0
|
|||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.13.0
|
||||
authlib==1.7.2
|
||||
joserfc==1.7.4
|
||||
|
||||
requests==2.34.2
|
||||
aiohttp==3.13.5 # do not update to 3.13.3 - broken
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
fastapi==0.136.3
|
||||
uvicorn[standard]==0.41.0
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.27
|
||||
python-multipart==0.0.32
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.16.2
|
||||
|
|
@ -11,6 +11,7 @@ bcrypt==5.0.0
|
|||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.13.0
|
||||
authlib==1.7.2
|
||||
joserfc==1.7.4
|
||||
|
||||
requests==2.34.2
|
||||
aiohttp==3.13.5 # do not update to 3.13.3 - broken
|
||||
|
|
@ -89,7 +90,7 @@ soundfile==0.13.1
|
|||
|
||||
pillow==12.2.0
|
||||
opencv-python-headless==4.13.0.92
|
||||
rapidocr-onnxruntime==1.4.4
|
||||
rapidocr==3.9.2
|
||||
rank-bm25==0.2.2
|
||||
|
||||
onnxruntime==1.26.0
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ dependencies = [
|
|||
"fastapi==0.136.3",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
"pydantic==2.13.4",
|
||||
"python-multipart==0.0.27",
|
||||
"python-multipart==0.0.32",
|
||||
"itsdangerous==2.2.0",
|
||||
|
||||
"python-socketio==5.16.2",
|
||||
|
|
@ -19,6 +19,7 @@ dependencies = [
|
|||
"argon2-cffi==25.1.0",
|
||||
"PyJWT[crypto]==2.13.0",
|
||||
"authlib==1.7.2",
|
||||
"joserfc==1.7.4",
|
||||
|
||||
"requests==2.34.2",
|
||||
"aiohttp==3.13.5", # do not update to 3.13.3 - broken
|
||||
|
|
@ -97,7 +98,7 @@ dependencies = [
|
|||
|
||||
"pillow==12.2.0",
|
||||
"opencv-python-headless==4.13.0.92",
|
||||
"rapidocr-onnxruntime==1.4.4",
|
||||
"rapidocr==3.9.2",
|
||||
"rank-bm25==0.2.2",
|
||||
|
||||
"onnxruntime==1.26.0",
|
||||
|
|
|
|||
16
src/app.css
16
src/app.css
|
|
@ -6,18 +6,6 @@
|
|||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Mona Sans';
|
||||
src: url('/assets/fonts/Mona-Sans.woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'InstrumentSerif';
|
||||
src: url('/assets/fonts/InstrumentSerif-Regular.ttf');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Vazirmatn';
|
||||
src: url('/assets/fonts/Vazirmatn-Variable.ttf');
|
||||
|
|
@ -64,10 +52,6 @@ code {
|
|||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.font-secondary {
|
||||
font-family: 'InstrumentSerif', sans-serif;
|
||||
}
|
||||
|
||||
.app-muted {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -440,6 +440,62 @@ export const updateUserInfo = async (token: string, info: object) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getUserVariables = async (token: string) => {
|
||||
let error = null;
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateUserVariables = async (token: string, variables: Record<string, string>) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/variables/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
variables
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getAndUpdateUserLocation = async (token: string) => {
|
||||
const location = await getUserPosition().catch((err) => {
|
||||
console.error(err);
|
||||
|
|
|
|||
|
|
@ -251,14 +251,14 @@
|
|||
type="date"
|
||||
bind:value={customStart}
|
||||
max={customEnd || undefined}
|
||||
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
|
||||
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none dark:scheme-dark"
|
||||
/>
|
||||
<span class="text-xs text-gray-400">–</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={customEnd}
|
||||
min={customStart || undefined}
|
||||
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none"
|
||||
class="w-fit rounded-sm px-2 text-xs bg-transparent outline-none dark:scheme-dark"
|
||||
/>
|
||||
{/if}
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
{#if loaded}
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex flex-col w-full mb-2">
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<ManageOllama {urlIdx} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
ENABLE_CONTEXT_COMPACTION: false,
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD: 80000,
|
||||
CONTEXT_COMPACTION_TOKEN_CAP: 80000,
|
||||
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: 40,
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE: ''
|
||||
};
|
||||
|
||||
|
|
@ -273,6 +274,22 @@
|
|||
/>
|
||||
</AdminSettingField>
|
||||
|
||||
<AdminSettingField
|
||||
label={$i18n.t('Retained Messages')}
|
||||
description={$i18n.t(
|
||||
'Percentage of recent messages to keep after older messages are summarized.'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
max="50"
|
||||
step="1"
|
||||
class={inputClass}
|
||||
bind:value={chatConfig.CONTEXT_COMPACTION_RETENTION_PERCENTAGE}
|
||||
/>
|
||||
</AdminSettingField>
|
||||
|
||||
<AdminSettingField
|
||||
label={$i18n.t('Context Compaction Prompt')}
|
||||
description={$i18n.t(
|
||||
|
|
|
|||
|
|
@ -911,7 +911,7 @@
|
|||
: ''}"
|
||||
id="model-item-{model.id}"
|
||||
>
|
||||
<div class="self-center pr-1 text-gray-400 dark:text-gray-600">
|
||||
<div class="self-center pr-1 -ml-1 text-gray-400 dark:text-gray-600">
|
||||
<Tooltip
|
||||
content={canReorderModels
|
||||
? $i18n.t('Drag to reorder')
|
||||
|
|
@ -1094,7 +1094,7 @@
|
|||
{/if}
|
||||
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
class="hidden sm:flex self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedModelId = model.id;
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@
|
|||
type="date"
|
||||
bind:value={onceDate}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
|
||||
class="bg-transparent outline-hidden text-xs dark:scheme-dark"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
on:input={onChange}
|
||||
/>
|
||||
|
|
@ -239,7 +239,7 @@
|
|||
<input
|
||||
type="time"
|
||||
bind:value={onceTime}
|
||||
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
|
||||
class="bg-transparent outline-hidden text-xs dark:scheme-dark"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
on:input={onChange}
|
||||
/>
|
||||
|
|
@ -256,7 +256,7 @@
|
|||
minute = m;
|
||||
onChange();
|
||||
}}
|
||||
class="bg-transparent text-center outline-hidden text-xs dark:color-scheme-dark"
|
||||
class="bg-transparent text-center outline-hidden text-xs dark:scheme-dark"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -214,11 +214,11 @@
|
|||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('When')}</div>
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<input type="date" class="bg-transparent outline-hidden" bind:value={startDate} />
|
||||
<input type="date" class="bg-transparent outline-hidden dark:scheme-dark" bind:value={startDate} />
|
||||
{#if !allDay}
|
||||
<input type="time" class="bg-transparent outline-hidden" bind:value={startTime} />
|
||||
<input type="time" class="bg-transparent outline-hidden dark:scheme-dark" bind:value={startTime} />
|
||||
<span class="text-gray-300 dark:text-gray-600">–</span>
|
||||
<input type="time" class="bg-transparent outline-hidden" bind:value={endTime} />
|
||||
<input type="time" class="bg-transparent outline-hidden dark:scheme-dark" bind:value={endTime} />
|
||||
{/if}
|
||||
<label class="flex items-center gap-1.5 cursor-pointer text-xs text-gray-400 ml-auto">
|
||||
<input type="checkbox" class="accent-blue-500" bind:checked={allDay} />
|
||||
|
|
|
|||
|
|
@ -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,110 @@
|
|||
let chatFiles = [];
|
||||
let files = [];
|
||||
let params = {};
|
||||
let chatVariables = {};
|
||||
let showChatVariablesModal = false;
|
||||
let loadedChatIdProp = '';
|
||||
let currentDraftKey = '';
|
||||
|
||||
const mergeChatVariableSchemas = (modelIds = [], availableModels = []) => {
|
||||
const byKey: Record<string, any> = {};
|
||||
const conflicts: any[] = [];
|
||||
|
||||
for (const modelId of modelIds.filter(Boolean)) {
|
||||
const fields =
|
||||
availableModels.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 = (modelIds = [], values = {}, availableModels = []) => {
|
||||
const { fields, conflicts } = mergeChatVariableSchemas(modelIds, availableModels);
|
||||
const empty =
|
||||
fields.length > 0 &&
|
||||
fields.every((field) => !hasValue(values?.[field.key]) && !hasValue(field.default));
|
||||
const missing = fields.some(
|
||||
(field) => field.required && !hasValue(values?.[field.key]) && !hasValue(field.default)
|
||||
);
|
||||
const variables = fields.reduce(
|
||||
(acc, field) => {
|
||||
const { key, ...inputField } = field;
|
||||
acc[key] = {
|
||||
...inputField,
|
||||
default: hasValue(values?.[key]) ? values[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;
|
||||
}
|
||||
};
|
||||
|
||||
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 +617,7 @@
|
|||
currentId: null
|
||||
};
|
||||
params = {};
|
||||
chatVariables = {};
|
||||
chatFiles = [];
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
|
|
@ -585,16 +689,6 @@
|
|||
initiateOAuthRedirect(nextTool);
|
||||
};
|
||||
|
||||
let oldSelectedModelIds = [''];
|
||||
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
|
||||
onSelectedModelIdsChange();
|
||||
}
|
||||
|
||||
const onSelectedModelIdsChange = () => {
|
||||
resetInput();
|
||||
oldSelectedModelIds = structuredClone(selectedModelIds);
|
||||
};
|
||||
|
||||
const resetInput = async () => {
|
||||
selectedToolIds = [];
|
||||
selectedSkillIds = [];
|
||||
|
|
@ -897,7 +991,7 @@
|
|||
} else if (type === 'chat:message:follow_ups') {
|
||||
message.followUps = data.follow_ups;
|
||||
|
||||
if (autoScroll) {
|
||||
if (shouldAutoScrollResponse()) {
|
||||
scrollToBottom('smooth');
|
||||
}
|
||||
} else if (type === 'chat:outlet') {
|
||||
|
|
@ -1268,6 +1362,13 @@
|
|||
pageSubscribe();
|
||||
showControlsSubscribe();
|
||||
selectedFolderSubscribe();
|
||||
|
||||
// Clear the selected chat when leaving the chat surface (e.g. navigating
|
||||
// to the admin panel), otherwise the previously-viewed chat stays selected
|
||||
// in the sidebar and deleting/archiving it wrongly navigates away.
|
||||
chatId.set('');
|
||||
chatTitle.set('');
|
||||
|
||||
window.removeEventListener('message', onMessageHandler);
|
||||
$socket?.off('events', chatEventHandler);
|
||||
$socket?.off('connect', handleSocketConnect);
|
||||
|
|
@ -1703,6 +1804,7 @@
|
|||
|
||||
chatFiles = [];
|
||||
params = {};
|
||||
chatVariables = {};
|
||||
taskIds = null;
|
||||
chatTasks = [];
|
||||
|
||||
|
|
@ -1854,6 +1956,7 @@
|
|||
noteChatDebug('getTagsById completed', { tagCount: tags?.length ?? 0 });
|
||||
|
||||
const chatContent = chat.chat;
|
||||
chatVariables = chat?.variables ?? {};
|
||||
|
||||
if (chatContent) {
|
||||
noteChatDebug('chat payload found', {
|
||||
|
|
@ -2009,9 +2112,14 @@
|
|||
await messagesRef?.scrollToTop();
|
||||
};
|
||||
|
||||
const shouldAutoScrollResponse = () =>
|
||||
autoScroll && ($settings?.scrollOnResponseGeneration ?? true);
|
||||
|
||||
let scrollRAF = null;
|
||||
let contentsRAF = null;
|
||||
const scheduleScrollToBottom = () => {
|
||||
const scheduleResponseScrollToBottom = () => {
|
||||
if (!shouldAutoScrollResponse()) return;
|
||||
|
||||
if (!scrollRAF) {
|
||||
scrollRAF = requestAnimationFrame(async () => {
|
||||
scrollRAF = null;
|
||||
|
|
@ -2060,7 +2168,7 @@
|
|||
messages: messages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
content: getOutputText(m.output) || m.content,
|
||||
info: m.info ? m.info : undefined,
|
||||
timestamp: m.timestamp,
|
||||
...(m.sources ? { sources: m.sources } : {})
|
||||
|
|
@ -2330,7 +2438,7 @@
|
|||
history.messages[message.id] = message;
|
||||
|
||||
await tick();
|
||||
if (autoScroll) {
|
||||
if (shouldAutoScrollResponse()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
|
|
@ -2351,9 +2459,7 @@
|
|||
console.log(data);
|
||||
await tick();
|
||||
|
||||
if (autoScroll) {
|
||||
scheduleScrollToBottom();
|
||||
}
|
||||
scheduleResponseScrollToBottom();
|
||||
};
|
||||
|
||||
//////////////////////////
|
||||
|
|
@ -2552,6 +2658,16 @@
|
|||
toast.error($i18n.t('Model not selected'));
|
||||
return;
|
||||
}
|
||||
const form = getChatVariablesForm(selectedModelIds, chatVariables, $models);
|
||||
if (form.conflicts.length > 0) {
|
||||
showChatVariablesModal = true;
|
||||
toast.error($i18n.t('Chat Variables have conflicting model definitions'));
|
||||
return;
|
||||
}
|
||||
if (form.missing || form.empty) {
|
||||
showChatVariablesModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
files.length > 0 &&
|
||||
|
|
@ -2968,6 +3084,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 +3120,7 @@
|
|||
$user?.email
|
||||
)
|
||||
},
|
||||
...(useChatVariablesFallback ? { chat_variables: chatVariables } : {}),
|
||||
model_item: $models.find((m) => m.id === model.id),
|
||||
|
||||
session_id: $socket?.id,
|
||||
|
|
@ -3100,7 +3219,9 @@
|
|||
}
|
||||
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
if (shouldAutoScrollResponse()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAIError = async (error, responseMessage) => {
|
||||
|
|
@ -3173,7 +3294,7 @@
|
|||
|
||||
history.messages[history.currentId] = responseMessage;
|
||||
|
||||
if (autoScroll) {
|
||||
if (shouldAutoScrollResponse()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
|
@ -3321,9 +3442,7 @@
|
|||
history.messages[messageId] = message;
|
||||
}
|
||||
|
||||
if (autoScroll) {
|
||||
scheduleScrollToBottom();
|
||||
}
|
||||
scheduleResponseScrollToBottom();
|
||||
}
|
||||
|
||||
await saveChatHandler(_chatId, history);
|
||||
|
|
@ -3352,7 +3471,8 @@
|
|||
tags: [],
|
||||
timestamp: Date.now()
|
||||
},
|
||||
$selectedFolder?.id
|
||||
$selectedFolder?.id,
|
||||
chatVariables
|
||||
);
|
||||
|
||||
_chatId = chat.id;
|
||||
|
|
@ -3515,6 +3635,47 @@
|
|||
|
||||
<audio id="audioElement" style="display: none;"></audio>
|
||||
|
||||
{#if getChatVariablesForm(selectedModelIds, chatVariables, $models).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 getChatVariablesForm(selectedModelIds, chatVariables, $models).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={getChatVariablesForm(selectedModelIds, chatVariables, $models).variables}
|
||||
onSave={saveChatVariables}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<WebSearchConfirmDialog
|
||||
bind:show={showWebSearchConfirm}
|
||||
title={$i18n.t('Use Web Search?')}
|
||||
|
|
@ -3673,7 +3834,8 @@
|
|||
messages: messages,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
null
|
||||
null,
|
||||
chatVariables
|
||||
);
|
||||
|
||||
if (savedChat) {
|
||||
|
|
@ -3726,6 +3888,7 @@
|
|||
{mergeResponses}
|
||||
{chatActionHandler}
|
||||
{addMessages}
|
||||
allowDelete={!(generating || taskIds?.length)}
|
||||
forkHandler={generating || taskIds?.length ? null : handleForkChat}
|
||||
topPadding={!embedded}
|
||||
bottomPadding={files.length > 0}
|
||||
|
|
@ -3819,6 +3982,9 @@
|
|||
}
|
||||
}}
|
||||
onWebSearchToggle={handleWebSearchToggle}
|
||||
on:chatVariables={() => {
|
||||
showChatVariablesModal = true;
|
||||
}}
|
||||
on:submit={async (e) => {
|
||||
clearDraft($chatId);
|
||||
if (e.detail || files.length > 0) {
|
||||
|
|
@ -3895,6 +4061,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 +4099,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;
|
||||
|
|
@ -1095,7 +1100,10 @@
|
|||
shiftKey = true;
|
||||
}
|
||||
|
||||
if (matchKeybinding(e) === Shortcut.TOGGLE_DICTATION) {
|
||||
if (
|
||||
$settings?.keyboardShortcuts !== false &&
|
||||
matchKeybinding(e) === Shortcut.TOGGLE_DICTATION
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (recording) {
|
||||
// Confirm and stop recording
|
||||
|
|
@ -2233,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')}>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@
|
|||
|
||||
export let show = false;
|
||||
export let variables = {};
|
||||
export let title = $i18n.t('Input Variables');
|
||||
|
||||
export let onSave = (e) => {};
|
||||
|
||||
let loading = true;
|
||||
let variableValues = {};
|
||||
let variablesKey = '';
|
||||
|
||||
const getVariableLabel = (variable) => variables[variable]?.label ?? variable;
|
||||
const getVariablesKey = (value) => JSON.stringify(value ?? {});
|
||||
|
||||
const submitHandler = async () => {
|
||||
// Normalize Windows CRLF (\r\n) to LF (\n) for all string values
|
||||
|
|
@ -59,15 +64,25 @@
|
|||
}
|
||||
};
|
||||
|
||||
$: if (!show) {
|
||||
variablesKey = '';
|
||||
}
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
const key = getVariablesKey(variables);
|
||||
if (key !== variablesKey) {
|
||||
variablesKey = key;
|
||||
init();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:show size="md">
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
|
||||
<div class=" text-sm font-medium self-center">{$i18n.t('Input Variables')}</div>
|
||||
<div class=" text-sm font-medium self-center">
|
||||
{title}
|
||||
</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={() => {
|
||||
|
|
@ -78,7 +93,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<form
|
||||
class="flex flex-col w-full"
|
||||
|
|
@ -95,10 +110,10 @@
|
|||
<div class=" py-0.5 w-full justify-between">
|
||||
<div class="flex w-full justify-between mb-1.5">
|
||||
<div class=" self-center text-xs font-normal">
|
||||
{variable}
|
||||
{getVariableLabel(variable)}
|
||||
|
||||
{#if variables[variable]?.required ?? false}
|
||||
<span class=" text-gray-500">*{$i18n.t('required')}</span>
|
||||
<span class="ml-1 text-gray-500">* {$i18n.t('required')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -111,13 +126,11 @@
|
|||
bind:value={variableValues[variable]}
|
||||
id="input-variable-{idx}"
|
||||
>
|
||||
{#if variables[variable]?.placeholder}
|
||||
<option value="" disabled selected>
|
||||
{variables[variable].placeholder}
|
||||
</option>
|
||||
{/if}
|
||||
<option value="" disabled>
|
||||
{variables[variable]?.placeholder ?? $i18n.t('Select an option')}
|
||||
</option>
|
||||
{#each variables[variable]?.options ?? [] as option}
|
||||
<option value={option} selected={option === variableValues[variable]}>
|
||||
<option value={option}>
|
||||
{option}
|
||||
</option>
|
||||
{/each}
|
||||
|
|
@ -175,7 +188,7 @@
|
|||
{:else if variables[variable]?.type === 'date'}
|
||||
<input
|
||||
type="date"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30 dark:scheme-dark"
|
||||
placeholder={variables[variable]?.placeholder ?? ''}
|
||||
bind:value={variableValues[variable]}
|
||||
autocomplete="off"
|
||||
|
|
@ -186,7 +199,7 @@
|
|||
{:else if variables[variable]?.type === 'datetime-local'}
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30 dark:scheme-dark"
|
||||
placeholder={variables[variable]?.placeholder ?? ''}
|
||||
bind:value={variableValues[variable]}
|
||||
autocomplete="off"
|
||||
|
|
@ -208,7 +221,7 @@
|
|||
{:else if variables[variable]?.type === 'month'}
|
||||
<input
|
||||
type="month"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30 dark:scheme-dark"
|
||||
placeholder={variables[variable]?.placeholder ?? ''}
|
||||
bind:value={variableValues[variable]}
|
||||
autocomplete="off"
|
||||
|
|
@ -283,7 +296,7 @@
|
|||
{:else if variables[variable]?.type === 'time'}
|
||||
<input
|
||||
type="time"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30 dark:scheme-dark"
|
||||
placeholder={variables[variable]?.placeholder ?? ''}
|
||||
bind:value={variableValues[variable]}
|
||||
autocomplete="off"
|
||||
|
|
|
|||
|
|
@ -300,6 +300,9 @@
|
|||
if (transcribe) {
|
||||
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
|
||||
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
|
||||
// reset accumulated transcription from previous sessions
|
||||
transcription = '';
|
||||
|
||||
// Create a SpeechRecognition object
|
||||
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
|
||||
|
||||
|
|
@ -353,13 +356,21 @@
|
|||
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
|
||||
onCancel();
|
||||
|
||||
stopRecording();
|
||||
cancelRecording();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRecording = async () => {
|
||||
if (speechRecognition) {
|
||||
// detach onend so cancelling does not confirm the transcription
|
||||
speechRecognition.onend = null;
|
||||
}
|
||||
await stopRecording();
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (recording && mediaRecorder) {
|
||||
await mediaRecorder.stop();
|
||||
|
|
@ -411,7 +422,7 @@
|
|||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
stopRecording();
|
||||
cancelRecording();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
|
@ -468,7 +479,7 @@
|
|||
|
||||
rounded-full"
|
||||
on:click={async () => {
|
||||
stopRecording();
|
||||
cancelRecording();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
export let forkHandler: Function | null = null;
|
||||
|
||||
export let readOnly = false;
|
||||
export let allowDelete = true;
|
||||
export let compactPreview = false;
|
||||
export let editCodeBlock = true;
|
||||
|
||||
|
|
@ -89,7 +90,7 @@
|
|||
let message = history.messages[history.currentId];
|
||||
const visitedMessageIds = new Set();
|
||||
|
||||
while (message && (messagesCount !== null ? _messages.length <= messagesCount : true)) {
|
||||
while (message && (messagesCount !== null ? _messages.length < messagesCount : true)) {
|
||||
if (visitedMessageIds.has(message.id)) {
|
||||
console.warn('Circular dependency detected in message history', message.id);
|
||||
break;
|
||||
|
|
@ -555,6 +556,7 @@
|
|||
{mergeResponses}
|
||||
{addMessages}
|
||||
{forkHandler}
|
||||
{allowDelete}
|
||||
{triggerScroll}
|
||||
{readOnly}
|
||||
{compactPreview}
|
||||
|
|
|
|||
|
|
@ -42,17 +42,22 @@
|
|||
export let forkHandler: Function | null = null;
|
||||
export let triggerScroll;
|
||||
export let readOnly = false;
|
||||
export let allowDelete = true;
|
||||
export let compactPreview = false;
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
export let onInsertToNote: ((content: string) => void) | null = null;
|
||||
|
||||
// Safari's content-visibility implementation has paint bugs that leave
|
||||
// on-screen messages blank (#26712), so skip virtualization there
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="listitem"
|
||||
class="flex flex-col justify-between px-5 mb-3 w-full {($settings?.widescreenMode ?? null)
|
||||
class="flex flex-col justify-between px-3.5 mb-3 w-full {($settings?.widescreenMode ?? null)
|
||||
? 'max-w-full'
|
||||
: 'max-w-[58rem]'} mx-auto rounded-lg group message-listitem"
|
||||
: 'max-w-[58rem]'} mx-auto rounded-lg group {isSafari ? '' : 'message-listitem'}"
|
||||
>
|
||||
{#if history.messages[messageId]}
|
||||
{#if history.messages[messageId].role === 'user'}
|
||||
|
|
@ -72,6 +77,7 @@
|
|||
{showNextMessage}
|
||||
{editMessage}
|
||||
{deleteMessage}
|
||||
{allowDelete}
|
||||
{readOnly}
|
||||
{compactPreview}
|
||||
{editCodeBlock}
|
||||
|
|
@ -97,6 +103,7 @@
|
|||
{actionMessage}
|
||||
{submitMessage}
|
||||
{deleteMessage}
|
||||
{allowDelete}
|
||||
{continueResponse}
|
||||
{regenerateResponse}
|
||||
{addMessages}
|
||||
|
|
@ -122,6 +129,7 @@
|
|||
{actionMessage}
|
||||
{submitMessage}
|
||||
{deleteMessage}
|
||||
{allowDelete}
|
||||
{continueResponse}
|
||||
{regenerateResponse}
|
||||
{mergeResponses}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
|
||||
export let isLastMessage;
|
||||
export let readOnly = false;
|
||||
export let allowDelete = true;
|
||||
export let compactPreview = false;
|
||||
export let editCodeBlock = true;
|
||||
|
||||
|
|
@ -377,6 +378,7 @@
|
|||
{saveMessage}
|
||||
{rateMessage}
|
||||
{deleteMessage}
|
||||
{allowDelete}
|
||||
{actionMessage}
|
||||
{submitMessage}
|
||||
{continueResponse}
|
||||
|
|
|
|||
|
|
@ -130,14 +130,14 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-center">
|
||||
<div class=" relative w-fit overflow-x-auto scrollbar-none">
|
||||
<div class="mt-1.5 w-fit flex gap-1 pb-2">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<div class="relative mx-auto min-w-[19rem] max-w-80">
|
||||
<div class="mt-1.5 grid grid-cols-10 gap-1 pb-2">
|
||||
<!-- 1-10 scale -->
|
||||
{#each Array.from({ length: 10 }).map((_, i) => i + 1) as rating}
|
||||
<button
|
||||
aria-label={$i18n.t('Rate {{rating}} out of 10', { rating })}
|
||||
class="size-7 text-sm border border-gray-100/30 dark:border-gray-850/30 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating ===
|
||||
class="aspect-square w-full text-sm border border-gray-100/30 dark:border-gray-850/30 hover:bg-gray-50 dark:hover:bg-gray-850 {detailedRating ===
|
||||
rating
|
||||
? 'bg-gray-100 dark:bg-gray-800'
|
||||
: ''} transition rounded-full disabled:cursor-not-allowed disabled:text-gray-500 disabled:bg-white dark:disabled:bg-gray-900"
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@
|
|||
|
||||
export let isLastMessage = true;
|
||||
export let readOnly = false;
|
||||
export let allowDelete = true;
|
||||
export let compactPreview = false;
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
|
|
@ -1074,37 +1075,6 @@
|
|||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{#if message.done && !readOnly && forkHandler}
|
||||
<Tooltip content="Fork chat" placement="bottom">
|
||||
<button
|
||||
aria-label="Fork chat"
|
||||
class="{isLastMessage || ($settings?.highContrastMode ?? false)
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
|
||||
on:click={() => {
|
||||
forkHandler?.(message.id);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 12H9" />
|
||||
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
|
||||
<path d="M17 4L20 7L17 10" />
|
||||
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
|
||||
<path d="M17 14L20 17L17 20" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if onInsertToNote && visibleResponseContent}
|
||||
<Tooltip content={$i18n.t('Insert into note')} placement="bottom">
|
||||
<button
|
||||
|
|
@ -1369,6 +1339,37 @@
|
|||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if message.done && !readOnly && forkHandler}
|
||||
<Tooltip content="Fork chat" placement="bottom">
|
||||
<button
|
||||
aria-label="Fork chat"
|
||||
class="{isLastMessage || ($settings?.highContrastMode ?? false)
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
|
||||
on:click={() => {
|
||||
forkHandler?.(message.id);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 12H9" />
|
||||
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
|
||||
<path d="M17 4L20 7L17 10" />
|
||||
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
|
||||
<path d="M17 14L20 17L17 20" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? true)}
|
||||
{#if $settings?.regenerateMenu ?? true}
|
||||
<button
|
||||
|
|
@ -1482,7 +1483,7 @@
|
|||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete_message ?? true)}
|
||||
{#if siblings.length > 1}
|
||||
{#if allowDelete && siblings.length > 1}
|
||||
<Tooltip content={$i18n.t('Delete')} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
|
||||
export let isFirstMessage: boolean;
|
||||
export let readOnly: boolean;
|
||||
export let allowDelete = true;
|
||||
export let compactPreview = false;
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
|
|
@ -578,7 +579,7 @@
|
|||
{/if}
|
||||
|
||||
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.delete_message ?? false)}
|
||||
{#if !compactPreview && !readOnly && (!isFirstMessage || siblings.length > 1)}
|
||||
{#if !compactPreview && !readOnly && allowDelete && (!isFirstMessage || siblings.length > 1)}
|
||||
<Tooltip content={$i18n.t('Delete')} placement="bottom">
|
||||
<button
|
||||
class="{($settings?.highContrastMode ?? false)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
|
||||
|
||||
import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
|
||||
import { deleteModelById } from '$lib/apis/models';
|
||||
import { unloadModel } from '$lib/apis';
|
||||
|
||||
import {
|
||||
|
|
@ -629,12 +630,25 @@
|
|||
const model = deleteModelTarget;
|
||||
if (!model) return;
|
||||
|
||||
const res = await deleteModel(localStorage.token, model.id).catch((error) => {
|
||||
toast.error($i18n.t('Error deleting model: {{error}}', { error }));
|
||||
});
|
||||
let success = false;
|
||||
|
||||
if (res) {
|
||||
// $i18n.t('Model {{modelId}} not found')
|
||||
if (model?.info?.base_model_id) {
|
||||
// Workspace model: only delete the workspace model record, not the underlying base model
|
||||
const res = await deleteModelById(localStorage.token, model.id).catch((error) => {
|
||||
toast.error($i18n.t('Error deleting model: {{error}}', { error }));
|
||||
return null;
|
||||
});
|
||||
success = !!res;
|
||||
} else {
|
||||
// Base Ollama model: delete from Ollama directly
|
||||
const res = await deleteModel(localStorage.token, model.id).catch((error) => {
|
||||
toast.error($i18n.t('Error deleting model: {{error}}', { error }));
|
||||
return null;
|
||||
});
|
||||
success = !!res;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
toast.success(
|
||||
$i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id })
|
||||
);
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@
|
|||
{onChange}
|
||||
{onUpload}
|
||||
{onWebSearchToggle}
|
||||
on:chatVariables
|
||||
on:submit={(e) => {
|
||||
dispatch('submit', e.detail);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { user, config } from '$lib/stores';
|
||||
import { updateUserProfile, createAPIKey, getAPIKey, getSessionUser } from '$lib/apis/auths';
|
||||
import { getUserVariables, updateUserVariables } from '$lib/apis/users';
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
import UpdatePassword from './Account/UpdatePassword.svelte';
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import UserProfileImage from './Account/UserProfileImage.svelte';
|
||||
import UserSettingField from './UserSettingField.svelte';
|
||||
import UserSettingRow from './UserSettingRow.svelte';
|
||||
|
|
@ -38,15 +40,96 @@
|
|||
|
||||
let APIKey = '';
|
||||
let APIKeyCopied = false;
|
||||
let variableRows: { key: string; value: string }[] = [];
|
||||
let variableModalOpen = false;
|
||||
let variableFormIndex: number | null = null;
|
||||
let variableFormKey = '';
|
||||
let variableFormValue = '';
|
||||
|
||||
const textareaClass =
|
||||
'w-full resize-y rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
|
||||
const inputClass =
|
||||
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
|
||||
const variableValueClass =
|
||||
'w-full resize-none rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 py-1.5 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
|
||||
const actionButtonClass =
|
||||
'text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white';
|
||||
const variableRowClass = 'flex w-full items-center gap-1.5';
|
||||
const variableKeyRegex = /^[a-z][a-z0-9_]*$/;
|
||||
|
||||
const setVariableRows = (variables = {}) => {
|
||||
variableRows = Object.entries(variables).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value ?? '')
|
||||
}));
|
||||
};
|
||||
|
||||
const openVariableModal = (idx: number | null = null) => {
|
||||
const row = idx === null ? null : variableRows[idx];
|
||||
variableFormIndex = idx;
|
||||
variableFormKey = row?.key ?? '';
|
||||
variableFormValue = row?.value ?? '';
|
||||
variableModalOpen = true;
|
||||
};
|
||||
|
||||
const removeVariable = (idx: number) => {
|
||||
variableRows = variableRows.filter((_, rowIdx) => rowIdx !== idx);
|
||||
};
|
||||
|
||||
const saveVariableForm = () => {
|
||||
const key = variableFormKey.trim();
|
||||
|
||||
if (!variableKeyRegex.test(key)) {
|
||||
toast.error($i18n.t('Variable keys must use lowercase snake case.'));
|
||||
return;
|
||||
}
|
||||
if (variableRows.some((row, idx) => idx !== variableFormIndex && row.key === key)) {
|
||||
toast.error($i18n.t('Variable keys must be unique.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const row = { key, value: variableFormValue ?? '' };
|
||||
variableRows =
|
||||
variableFormIndex === null
|
||||
? [...variableRows, row]
|
||||
: variableRows.map((current, idx) => (idx === variableFormIndex ? row : current));
|
||||
variableModalOpen = false;
|
||||
};
|
||||
|
||||
const deleteVariableForm = () => {
|
||||
if (variableFormIndex !== null) {
|
||||
removeVariable(variableFormIndex);
|
||||
}
|
||||
variableModalOpen = false;
|
||||
};
|
||||
|
||||
const getVariablesPayload = () => {
|
||||
const variables: Record<string, string> = {};
|
||||
for (const row of variableRows) {
|
||||
const key = row.key.trim();
|
||||
if (!key && !row.value) {
|
||||
continue;
|
||||
}
|
||||
if (!variableKeyRegex.test(key)) {
|
||||
throw $i18n.t('Variable keys must use lowercase snake case.');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(variables, key)) {
|
||||
throw $i18n.t('Variable keys must be unique.');
|
||||
}
|
||||
variables[key] = row.value ?? '';
|
||||
}
|
||||
return variables;
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
let variables: Record<string, string>;
|
||||
try {
|
||||
variables = getVariablesPayload();
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name !== $user?.name) {
|
||||
if (profileImageUrl === generateInitialsImage($user?.name) || profileImageUrl === '') {
|
||||
profileImageUrl = generateInitialsImage(name);
|
||||
|
|
@ -63,7 +146,13 @@
|
|||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
const variablesRes = await updateUserVariables(localStorage.token, variables).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (updatedUser && variablesRes) {
|
||||
setVariableRows(variablesRes.variables ?? {});
|
||||
// Get Session User Info
|
||||
const sessionUser = await getSessionUser(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
|
|
@ -102,6 +191,12 @@
|
|||
dateOfBirth = user?.date_of_birth ?? '';
|
||||
}
|
||||
|
||||
const userVariables = await getUserVariables(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
setVariableRows(userVariables?.variables ?? {});
|
||||
|
||||
// Only fetch API key if the feature is enabled and user has permission
|
||||
if (
|
||||
user &&
|
||||
|
|
@ -197,7 +292,7 @@
|
|||
description={$i18n.t('Set the birth date saved with your profile.')}
|
||||
>
|
||||
<input
|
||||
class={inputClass}
|
||||
class="{inputClass} dark:scheme-dark"
|
||||
type="date"
|
||||
aria-label={$i18n.t('Birth Date')}
|
||||
bind:value={dateOfBirth}
|
||||
|
|
@ -206,6 +301,47 @@
|
|||
</UserSettingField>
|
||||
</UserSettingSection>
|
||||
|
||||
<section class="mt-4 w-full">
|
||||
<div
|
||||
class="mb-0.5 flex items-center justify-between gap-2 text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{$i18n.t('User Variables')}
|
||||
<span class="text-gray-400 dark:text-gray-600">{variableRows.length}</span>
|
||||
</div>
|
||||
<button class={actionButtonClass} type="button" on:click={() => openVariableModal()}>
|
||||
{$i18n.t('Add')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 py-1">
|
||||
{#each variableRows as row, idx}
|
||||
<div class={variableRowClass}>
|
||||
<div class="min-w-0 truncate font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{row.key || $i18n.t('key_name')}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-gray-500">
|
||||
{row.value || $i18n.t('Empty')}
|
||||
</div>
|
||||
<button class={actionButtonClass} type="button" on:click={() => openVariableModal(idx)}>
|
||||
{$i18n.t('Edit')}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if variableRows.length === 0}
|
||||
<div class="text-xs text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('No user variables configured.')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Use these in model system prompts as {{example}}.', {
|
||||
example: '{{user.variables.key_name}}'
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if $config?.features.enable_login_form && $config?.features.enable_password_change_form}
|
||||
<UserSettingSection title={$i18n.t('Password')}>
|
||||
<UpdatePassword />
|
||||
|
|
@ -397,3 +533,68 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal size="sm" bind:show={variableModalOpen}>
|
||||
<form class="p-4" on:submit|preventDefault={saveVariableForm}>
|
||||
<h2 class="mb-3 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{variableFormIndex === null ? $i18n.t('Add User Variable') : $i18n.t('Edit User Variable')}
|
||||
</h2>
|
||||
|
||||
<div class="mb-1 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Key')}
|
||||
</div>
|
||||
<input
|
||||
class={inputClass}
|
||||
type="text"
|
||||
bind:value={variableFormKey}
|
||||
aria-label={$i18n.t('Variable key')}
|
||||
placeholder={$i18n.t('key_name')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
|
||||
<div class="mb-1 mt-3 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Value')}
|
||||
</div>
|
||||
<Textarea
|
||||
className={variableValueClass}
|
||||
rows="6"
|
||||
minSize={132}
|
||||
bind:value={variableFormValue}
|
||||
ariaLabel={$i18n.t('Variable value')}
|
||||
placeholder={$i18n.t('Value')}
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{#if variableFormIndex !== null}
|
||||
<button
|
||||
class="text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
|
||||
type="button"
|
||||
on:click={deleteVariableForm}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="text-xs text-gray-500 transition-colors hover:text-gray-900 dark:text-gray-500 dark:hover:text-white"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
variableModalOpen = false;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full bg-black px-3.5 py-1.5 text-sm font-normal text-white transition hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
}}>{show ? $i18n.t('Hide') : $i18n.t('Show')}</button
|
||||
>
|
||||
</div>
|
||||
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
<p class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('Update the password used for email and password sign-in.')}
|
||||
</p>
|
||||
|
||||
|
|
@ -113,10 +113,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button class={actionButtonClass}>
|
||||
{$i18n.t('Update password')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
let widescreenMode = false;
|
||||
let splitLargeChunks = false;
|
||||
let scrollOnBranchChange = true;
|
||||
let scrollOnResponseGeneration = true;
|
||||
let showFilesOnTerminalSelect = true;
|
||||
let userLocation = false;
|
||||
|
||||
|
|
@ -250,6 +251,7 @@
|
|||
widescreenMode = $settings?.widescreenMode ?? false;
|
||||
splitLargeChunks = $settings?.splitLargeChunks ?? false;
|
||||
scrollOnBranchChange = $settings?.scrollOnBranchChange ?? true;
|
||||
scrollOnResponseGeneration = $settings?.scrollOnResponseGeneration ?? true;
|
||||
showFilesOnTerminalSelect = $settings?.showFilesOnTerminalSelect ?? true;
|
||||
|
||||
temporaryChatByDefault = $settings?.temporaryChatByDefault ?? false;
|
||||
|
|
@ -929,6 +931,28 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class={settingRowClass}>
|
||||
<div id="response-auto-scroll-label" class={settingLabelClass}>
|
||||
{$i18n.t('Response Auto-Scroll')}
|
||||
</div>
|
||||
|
||||
<div class={settingControlClass}>
|
||||
<Switch
|
||||
ariaLabelledbyId="response-auto-scroll-label"
|
||||
tooltip={true}
|
||||
bind:state={scrollOnResponseGeneration}
|
||||
on:change={() => {
|
||||
saveSettings({ scrollOnResponseGeneration });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class={settingDescriptionClass}>
|
||||
{$i18n.t('Follow assistant responses as they are generated.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class={settingRowClass}>
|
||||
<div id="insert-suggestion-prompt-label" class={settingLabelClass}>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<form class="flex flex-col w-full" on:submit|preventDefault={submitHandler}>
|
||||
<div class="px-1">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
} from '$lib/shortcuts';
|
||||
import type { KeybindingsMap, ShortcutDefinition } from '$lib/shortcuts';
|
||||
import { settings } from '$lib/stores';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import ShortcutItem from '../ShortcutItem.svelte';
|
||||
|
||||
type ShortcutEntry = {
|
||||
|
|
@ -29,12 +30,16 @@
|
|||
|
||||
const i18n: I18nStore = getContext('i18n');
|
||||
|
||||
export let saveSettings: (updated: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
let categorizedShortcuts: CategorizedShortcuts = {};
|
||||
let isMac = false;
|
||||
let recordingShortcut: Shortcut | null = null;
|
||||
let enableKeyboardShortcuts = true;
|
||||
|
||||
onMount(() => {
|
||||
isMac = /Mac/i.test(navigator.userAgent);
|
||||
enableKeyboardShortcuts = $settings?.keyboardShortcuts ?? true;
|
||||
});
|
||||
|
||||
$: {
|
||||
|
|
@ -112,6 +117,31 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<div
|
||||
id="enable-keyboard-shortcuts-label"
|
||||
class="min-w-0 text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{$i18n.t('Enable Keyboard Shortcuts')}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-end gap-1.5">
|
||||
<Switch
|
||||
ariaLabelledbyId="enable-keyboard-shortcuts-label"
|
||||
tooltip={true}
|
||||
bind:state={enableKeyboardShortcuts}
|
||||
on:change={() => {
|
||||
saveSettings({ keyboardShortcuts: enableKeyboardShortcuts });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{$i18n.t('When disabled, keyboard shortcuts will not trigger any actions.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto scrollbar-hover pr-1.5">
|
||||
<div class="flex items-center gap-2 px-1 pb-1">
|
||||
<span class="flex-1 text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
<section class="w-full {first ? '' : 'mt-4'} {className}">
|
||||
{#if title}
|
||||
<h3 class="mb-2 text-xs text-gray-400 dark:text-gray-600">
|
||||
<h3 class="mb-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{title}
|
||||
</h3>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -259,8 +259,13 @@
|
|||
'pastelargetextasfile',
|
||||
'reset background',
|
||||
'resetbackground',
|
||||
'disable auto scroll',
|
||||
'disableautoscroll',
|
||||
'response auto copy',
|
||||
'response auto scroll',
|
||||
'response auto-scroll',
|
||||
'responseautocopy',
|
||||
'responseautoscroll',
|
||||
'rich text input for chat',
|
||||
'richtextinputforchat',
|
||||
'right to left',
|
||||
|
|
@ -1171,7 +1176,7 @@
|
|||
{:else if selectedTab === 'notifications'}
|
||||
<Notifications {saveSettings} />
|
||||
{:else if selectedTab === 'shortcuts'}
|
||||
<Shortcuts />
|
||||
<Shortcuts {saveSettings} />
|
||||
{:else if selectedTab === 'connections'}
|
||||
<Connections
|
||||
saveSettings={async (updated: Record<string, any>) => {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { formatChord, isConfigurableShortcut, keybindings, shortcuts } from '$lib/shortcuts';
|
||||
import type { Shortcut } from '$lib/shortcuts';
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
export let name: string;
|
||||
export let className = '';
|
||||
|
||||
let isMac = false;
|
||||
let mounted = false;
|
||||
let isVisible = true;
|
||||
|
||||
$: isVisible = $settings?.keyboardShortcuts !== false;
|
||||
|
||||
onMount(() => {
|
||||
isMac = /Mac/i.test(navigator.userAgent);
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
<script lang="ts">
|
||||
// @ts-ignore
|
||||
import fileSaver from 'file-saver';
|
||||
import type { Writable } from 'svelte/store';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext } from 'svelte';
|
||||
import {
|
||||
archiveChatById,
|
||||
getAllArchivedChats,
|
||||
getArchivedChatCount,
|
||||
getArchivedChatList,
|
||||
unarchiveAllChats
|
||||
} from '$lib/apis/chats';
|
||||
|
||||
import ChatsModal from './ChatsModal.svelte';
|
||||
import UnarchiveAllConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
|
||||
const i18n: Writable<any> = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
export let onUpdate = () => {};
|
||||
export let onDelete: (id: string) => void = () => {};
|
||||
|
||||
let loading = false;
|
||||
let chatList: any[] | null = null;
|
||||
let chatCount: number | null = null;
|
||||
let page = 1;
|
||||
|
||||
let query = '';
|
||||
let orderBy = 'updated_at';
|
||||
let direction = 'desc';
|
||||
|
||||
let allChatsLoaded = false;
|
||||
let chatListLoading = false;
|
||||
let searchDebounceTimeout: any;
|
||||
|
||||
let showUnarchiveAllConfirmDialog = false;
|
||||
|
||||
let filter: any = {};
|
||||
$: filter = {
|
||||
...(query ? { query } : {}),
|
||||
...(orderBy ? { order_by: orderBy } : {}),
|
||||
...(direction ? { direction } : {})
|
||||
};
|
||||
|
||||
$: if (filter !== null) {
|
||||
searchHandler();
|
||||
}
|
||||
|
||||
const searchHandler = async () => {
|
||||
if (!show) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchDebounceTimeout) {
|
||||
clearTimeout(searchDebounceTimeout);
|
||||
}
|
||||
|
||||
page = 1;
|
||||
chatList = null;
|
||||
|
||||
if (query === '') {
|
||||
chatList = await getArchivedChatList(localStorage.token, page, filter);
|
||||
} else {
|
||||
searchDebounceTimeout = setTimeout(async () => {
|
||||
chatList = await getArchivedChatList(localStorage.token, page, filter);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if ((chatList ?? []).length === 0) {
|
||||
allChatsLoaded = true;
|
||||
} else {
|
||||
allChatsLoaded = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreChats = async () => {
|
||||
chatListLoading = true;
|
||||
page += 1;
|
||||
|
||||
let newChatList = [];
|
||||
|
||||
if (query) {
|
||||
newChatList = await getArchivedChatList(localStorage.token, page, filter);
|
||||
} else {
|
||||
newChatList = await getArchivedChatList(localStorage.token, page, filter);
|
||||
}
|
||||
|
||||
// once the bottom of the list has been reached (no results) there is no need to continue querying
|
||||
allChatsLoaded = newChatList.length === 0;
|
||||
|
||||
if (newChatList.length > 0) {
|
||||
chatList = [...(chatList || []), ...newChatList];
|
||||
}
|
||||
|
||||
chatListLoading = false;
|
||||
};
|
||||
|
||||
const exportChatsHandler = async () => {
|
||||
const chats = await getAllArchivedChats(localStorage.token);
|
||||
let blob = new Blob([JSON.stringify(chats)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
saveAs(blob, `${$i18n.t('archived-chat-export')}-${Date.now()}.json`);
|
||||
};
|
||||
|
||||
const unarchiveHandler = async (chatId: string) => {
|
||||
const res = await archiveChatById(localStorage.token, chatId).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
chatList = chatList?.filter((c) => c.id !== chatId) ?? null;
|
||||
if (chatCount !== null) chatCount--;
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
const unarchiveAllHandler = async () => {
|
||||
loading = true;
|
||||
try {
|
||||
await unarchiveAllChats(localStorage.token);
|
||||
toast.success($i18n.t('All chats have been unarchived.'));
|
||||
onUpdate();
|
||||
await init();
|
||||
} catch (error) {
|
||||
toast.error(`${error}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
chatList = await getArchivedChatList(localStorage.token);
|
||||
chatCount = await getArchivedChatCount(localStorage.token);
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
</script>
|
||||
|
||||
<UnarchiveAllConfirmDialog
|
||||
bind:show={showUnarchiveAllConfirmDialog}
|
||||
message={$i18n.t('Are you sure you want to unarchive all archived chats?')}
|
||||
confirmLabel={$i18n.t('Unarchive All')}
|
||||
on:confirm={() => {
|
||||
unarchiveAllHandler();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChatsModal
|
||||
bind:show
|
||||
bind:query
|
||||
bind:orderBy
|
||||
bind:direction
|
||||
title={$i18n.t('Archived Chats')}
|
||||
emptyPlaceholder={$i18n.t('You have no archived conversations.')}
|
||||
count={chatCount}
|
||||
{chatList}
|
||||
{allChatsLoaded}
|
||||
{chatListLoading}
|
||||
onUpdate={() => {
|
||||
onUpdate();
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
if (chatCount !== null) chatCount--;
|
||||
onDelete(id);
|
||||
}}
|
||||
loadHandler={loadMoreChats}
|
||||
{unarchiveHandler}
|
||||
>
|
||||
<div slot="footer">
|
||||
<div class="flex flex-wrap text-sm font-normal gap-1.5 mt-2 m-1 justify-end w-full">
|
||||
<button
|
||||
class=" px-3.5 py-1.5 font-normal hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-800 rounded-3xl"
|
||||
disabled={loading}
|
||||
on:click={() => {
|
||||
showUnarchiveAllConfirmDialog = true;
|
||||
}}
|
||||
>
|
||||
{#if loading}
|
||||
<Spinner className="size-4" />
|
||||
{:else}
|
||||
{$i18n.t('Unarchive All Archived Chats')}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 font-normal hover:bg-black/5 dark:hover:bg-white/5 outline outline-1 outline-gray-100 dark:outline-gray-800 rounded-3xl"
|
||||
disabled={loading}
|
||||
on:click={() => {
|
||||
exportChatsHandler();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Export All Archived Chats')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ChatsModal>
|
||||
|
|
@ -271,6 +271,8 @@
|
|||
let selectedModels = [''];
|
||||
let history = null;
|
||||
let messages = null;
|
||||
let messagesContainerElement: HTMLElement | null = null;
|
||||
const messagesContainerId = 'chat-preview';
|
||||
|
||||
const searchFilterPrefixes = ['tag:', 'folder:', 'pinned:', 'archived:', 'shared:'];
|
||||
|
||||
|
|
@ -311,6 +313,26 @@
|
|||
loadChatPreview(selectedIdx);
|
||||
}
|
||||
|
||||
const scrollPreviewToBottom = async () => {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
}
|
||||
}, 80);
|
||||
};
|
||||
|
||||
const loadChatPreview = async (selectedIdx) => {
|
||||
if (!chatList || chatList.length === 0 || selectedIdx === null) {
|
||||
selectedChat = null;
|
||||
|
|
@ -336,6 +358,8 @@
|
|||
});
|
||||
|
||||
if (chat) {
|
||||
selectedChat = chat;
|
||||
|
||||
if (chat?.chat?.history) {
|
||||
selectedModels =
|
||||
(chat?.chat?.models ?? undefined) !== undefined
|
||||
|
|
@ -343,14 +367,8 @@
|
|||
: [chat?.chat?.models ?? ''];
|
||||
|
||||
history = chat?.chat?.history;
|
||||
messages = createMessagesList(chat?.chat?.history, chat?.chat?.history?.currentId);
|
||||
|
||||
// scroll to the bottom of the messages container
|
||||
await tick();
|
||||
const messagesContainerElement = document.getElementById('chat-preview');
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
}
|
||||
messages = [];
|
||||
await scrollPreviewToBottom();
|
||||
} else {
|
||||
messages = [];
|
||||
}
|
||||
|
|
@ -853,7 +871,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div
|
||||
id="chat-preview"
|
||||
id={messagesContainerId}
|
||||
bind:this={messagesContainerElement}
|
||||
class="hidden md:flex md:flex-1 w-full overflow-y-auto h-96 md:h-[40rem] scrollbar-hidden @container"
|
||||
>
|
||||
{#if messages === null}
|
||||
|
|
@ -871,8 +890,9 @@
|
|||
readOnly={true}
|
||||
{selectedModels}
|
||||
bind:history
|
||||
bind:messages
|
||||
autoScroll={true}
|
||||
{messagesContainerId}
|
||||
messagesCount={8}
|
||||
sendMessage={() => {}}
|
||||
continueResponse={() => {}}
|
||||
regenerateResponse={() => {}}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,15 @@
|
|||
let shiftKey = false;
|
||||
|
||||
let selectedChatId = null;
|
||||
|
||||
// Keep the optimistic sidebar highlight in sync with the active chat. Leaving the
|
||||
// chat view (e.g. navigating to an admin page) clears chatId, and programmatic
|
||||
// navigation such as cloning moves chatId to a different chat — in both cases the
|
||||
// previously-selected item must not stay highlighted. The optimistic on-click
|
||||
// highlight is preserved because a click sets selectedChatId without changing
|
||||
// chatId, so this reactive only re-runs once chatId catches up to the same value.
|
||||
$: selectedChatId = $chatId || null;
|
||||
|
||||
let showCreateChannel = false;
|
||||
|
||||
// Pagination variables
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<form
|
||||
class="flex flex-col w-full"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue