mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-09 03:18:18 +00:00
Merge branch 'open-webui:dev' into dev
This commit is contained in:
commit
9450d5a174
100 changed files with 6273 additions and 1940 deletions
|
|
@ -194,6 +194,15 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
|
|||
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
|
||||
)
|
||||
|
||||
# Header names for user info forwarding (customizable via environment variables)
|
||||
FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get("FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name")
|
||||
FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get("FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id")
|
||||
FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get("FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email")
|
||||
FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get("FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role")
|
||||
|
||||
# Header name for chat ID forwarding (customizable via environment variable)
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get("FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id")
|
||||
|
||||
# Experimental feature, may be removed in future
|
||||
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
|
||||
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ from open_webui.utils.plugin import (
|
|||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.tools import get_tools
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
|
||||
|
|
|
|||
|
|
@ -518,7 +518,6 @@ from open_webui.utils.middleware import (
|
|||
process_chat_payload,
|
||||
process_chat_response,
|
||||
)
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
from open_webui.utils.auth import (
|
||||
get_license_data,
|
||||
|
|
@ -1333,7 +1332,9 @@ class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
|
|||
token = None
|
||||
|
||||
if auth_header:
|
||||
scheme, token = auth_header.split(" ")
|
||||
parts = auth_header.split(" ", 1)
|
||||
if len(parts) == 2:
|
||||
token = parts[1]
|
||||
|
||||
# Only apply restrictions if an sk- API key is used
|
||||
if token and token.startswith("sk-"):
|
||||
|
|
@ -1806,6 +1807,16 @@ async def chat_completion(
|
|||
except Exception as e:
|
||||
log.debug(f"Error cleaning up: {e}")
|
||||
pass
|
||||
# Emit chat:active=false when task completes
|
||||
try:
|
||||
if metadata.get("chat_id"):
|
||||
event_emitter = get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter(
|
||||
{"type": "chat:active", "data": {"active": False}}
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Error emitting chat:active: {e}")
|
||||
|
||||
if (
|
||||
metadata.get("session_id")
|
||||
|
|
@ -1818,6 +1829,12 @@ async def chat_completion(
|
|||
process_chat(request, form_data, user, metadata, model),
|
||||
id=metadata["chat_id"],
|
||||
)
|
||||
# Emit chat:active=true when task starts
|
||||
event_emitter = get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter(
|
||||
{"type": "chat:active", "data": {"active": True}}
|
||||
)
|
||||
return {"status": True, "task_id": task_id}
|
||||
else:
|
||||
return await process_chat(request, form_data, user, metadata, model)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,350 @@
|
|||
"""Add access_grant table
|
||||
|
||||
Revision ID: f1e2d3c4b5a6
|
||||
Revises: 8452d01d26d7
|
||||
Create Date: 2026-02-05 10:00:00.000000
|
||||
|
||||
Migrates from JSON access_control columns to normalized access_grant table.
|
||||
Access control semantics:
|
||||
- NULL: Public access (all users can read) -> insert user:* for read
|
||||
- {}: Private/owner-only (no grants) -> insert nothing
|
||||
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from open_webui.migrations.util import get_existing_tables
|
||||
|
||||
revision: str = "f1e2d3c4b5a6"
|
||||
down_revision: Union[str, None] = "8452d01d26d7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
existing_tables = set(get_existing_tables())
|
||||
|
||||
# Create access_grant table
|
||||
if "access_grant" not in existing_tables:
|
||||
op.create_table(
|
||||
"access_grant",
|
||||
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
|
||||
sa.Column("resource_type", sa.Text(), nullable=False),
|
||||
sa.Column("resource_id", sa.Text(), nullable=False),
|
||||
sa.Column("principal_type", sa.Text(), nullable=False),
|
||||
sa.Column("principal_id", sa.Text(), nullable=False),
|
||||
sa.Column("permission", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission",
|
||||
name="uq_access_grant_grant",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_access_grant_resource",
|
||||
"access_grant",
|
||||
["resource_type", "resource_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"idx_access_grant_principal",
|
||||
"access_grant",
|
||||
["principal_type", "principal_id"],
|
||||
)
|
||||
|
||||
# Backfill existing access_control JSON data
|
||||
conn = op.get_bind()
|
||||
|
||||
# Tables with access_control JSON columns: (table_name, resource_type)
|
||||
resource_tables = [
|
||||
("knowledge", "knowledge"),
|
||||
("prompt", "prompt"),
|
||||
("tool", "tool"),
|
||||
("model", "model"),
|
||||
("note", "note"),
|
||||
("channel", "channel"),
|
||||
("file", "file"),
|
||||
]
|
||||
|
||||
now = int(time.time())
|
||||
inserted = set()
|
||||
|
||||
for table_name, resource_type in resource_tables:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
|
||||
# Query all rows
|
||||
try:
|
||||
result = conn.execute(
|
||||
sa.text(f'SELECT id, access_control FROM "{table_name}"')
|
||||
)
|
||||
rows = result.fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for row in rows:
|
||||
resource_id = row[0]
|
||||
access_control_json = row[1]
|
||||
|
||||
# Handle NULL or JSON "null" = public access (user:* for read)
|
||||
# Could be Python None (SQL NULL) or string "null" (JSON null)
|
||||
# EXCEPTION: files with NULL are PRIVATE (owner-only), not public
|
||||
is_null = (
|
||||
access_control_json is None or
|
||||
access_control_json == "null" or
|
||||
(isinstance(access_control_json, str) and access_control_json.strip().lower() == "null")
|
||||
)
|
||||
if is_null:
|
||||
# Files: NULL = private (no entry needed, owner has implicit access)
|
||||
# Other resources: NULL = public (insert user:* for read)
|
||||
if resource_type == "file":
|
||||
continue # Private - no entry needed
|
||||
|
||||
key = (resource_type, resource_id, "user", "*", "read")
|
||||
if key not in inserted:
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": "*",
|
||||
"permission": "read",
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# Handle JSON parsing
|
||||
if isinstance(access_control_json, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
access_control_json = json.loads(access_control_json)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Handle {} = private/owner-only - NO entries needed
|
||||
# Owner access is implicit, no grants to store
|
||||
if not access_control_json or not isinstance(access_control_json, dict):
|
||||
continue
|
||||
|
||||
# Check if it's effectively empty (no read/write keys with content)
|
||||
read_data = access_control_json.get("read", {})
|
||||
write_data = access_control_json.get("write", {})
|
||||
|
||||
has_read_grants = read_data.get("group_ids", []) or read_data.get(
|
||||
"user_ids", []
|
||||
)
|
||||
has_write_grants = write_data.get("group_ids", []) or write_data.get(
|
||||
"user_ids", []
|
||||
)
|
||||
|
||||
if not has_read_grants and not has_write_grants:
|
||||
# Empty permissions = private, no grants needed
|
||||
continue
|
||||
|
||||
# Extract permissions and insert into access_grant table
|
||||
for permission in ["read", "write"]:
|
||||
perm_data = access_control_json.get(permission, {})
|
||||
if not perm_data:
|
||||
continue
|
||||
|
||||
for group_id in perm_data.get("group_ids", []):
|
||||
key = (resource_type, resource_id, "group", group_id, permission)
|
||||
if key in inserted:
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "group",
|
||||
"principal_id": group_id,
|
||||
"permission": permission,
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for user_id in perm_data.get("user_ids", []):
|
||||
key = (resource_type, resource_id, "user", user_id, permission)
|
||||
if key in inserted:
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
|
||||
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": user_id,
|
||||
"permission": permission,
|
||||
"created_at": now,
|
||||
},
|
||||
)
|
||||
inserted.add(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Drop access_control columns from resource tables
|
||||
for table_name, _ in resource_tables:
|
||||
if table_name not in existing_tables:
|
||||
continue
|
||||
try:
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.drop_column("access_control")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
import json
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
# Resource tables mapping: (table_name, resource_type)
|
||||
resource_tables = [
|
||||
("knowledge", "knowledge"),
|
||||
("prompt", "prompt"),
|
||||
("tool", "tool"),
|
||||
("model", "model"),
|
||||
("note", "note"),
|
||||
("channel", "channel"),
|
||||
("file", "file"),
|
||||
]
|
||||
|
||||
# Step 1: Re-add access_control columns to resource tables
|
||||
for table_name, _ in resource_tables:
|
||||
try:
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.add_column(sa.Column("access_control", sa.JSON(), nullable=True))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 2: Query access_grant table and reconstruct JSON for each resource
|
||||
for table_name, resource_type in resource_tables:
|
||||
try:
|
||||
# Get all grants for this resource type
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT resource_id, principal_type, principal_id, permission
|
||||
FROM access_grant
|
||||
WHERE resource_type = :resource_type
|
||||
"""),
|
||||
{"resource_type": resource_type}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Group by resource_id and reconstruct JSON structure
|
||||
resource_grants = {}
|
||||
for row in rows:
|
||||
resource_id = row[0]
|
||||
principal_type = row[1]
|
||||
principal_id = row[2]
|
||||
permission = row[3]
|
||||
|
||||
if resource_id not in resource_grants:
|
||||
resource_grants[resource_id] = {
|
||||
"is_public": False,
|
||||
"read": {"group_ids": [], "user_ids": []},
|
||||
"write": {"group_ids": [], "user_ids": []},
|
||||
}
|
||||
|
||||
# Handle public access (user:* for read)
|
||||
if principal_type == "user" and principal_id == "*" and permission == "read":
|
||||
resource_grants[resource_id]["is_public"] = True
|
||||
continue
|
||||
|
||||
# Add to appropriate list
|
||||
if permission in ["read", "write"]:
|
||||
if principal_type == "group":
|
||||
if principal_id not in resource_grants[resource_id][permission]["group_ids"]:
|
||||
resource_grants[resource_id][permission]["group_ids"].append(principal_id)
|
||||
elif principal_type == "user":
|
||||
if principal_id not in resource_grants[resource_id][permission]["user_ids"]:
|
||||
resource_grants[resource_id][permission]["user_ids"].append(principal_id)
|
||||
|
||||
# Step 3: Update each resource with reconstructed JSON
|
||||
for resource_id, grants in resource_grants.items():
|
||||
if grants["is_public"]:
|
||||
# Public = NULL
|
||||
access_control_value = None
|
||||
elif (not grants["read"]["group_ids"] and not grants["read"]["user_ids"] and
|
||||
not grants["write"]["group_ids"] and not grants["write"]["user_ids"]):
|
||||
# No grants = should not happen (would mean no entries), default to {}
|
||||
access_control_value = json.dumps({})
|
||||
else:
|
||||
# Custom permissions
|
||||
access_control_value = json.dumps({
|
||||
"read": grants["read"],
|
||||
"write": grants["write"],
|
||||
})
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'),
|
||||
{"access_control": access_control_value, "id": resource_id}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 4: Set all resources WITHOUT entries to private
|
||||
# For files: NULL means private (owner-only), so leave as NULL
|
||||
# For other resources: {} means private, so update to {}
|
||||
if resource_type != "file":
|
||||
try:
|
||||
conn.execute(
|
||||
sa.text(f'''
|
||||
UPDATE "{table_name}"
|
||||
SET access_control = :private_value
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT resource_id FROM access_grant WHERE resource_type = :resource_type
|
||||
)
|
||||
AND access_control IS NULL
|
||||
'''),
|
||||
{"private_value": json.dumps({}), "resource_type": resource_type}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# For files, NULL stays NULL - no action needed
|
||||
|
||||
# Step 5: Drop the access_grant table
|
||||
op.drop_index("idx_access_grant_principal", table_name="access_grant")
|
||||
op.drop_index("idx_access_grant_resource", table_name="access_grant")
|
||||
op.drop_table("access_grant")
|
||||
776
backend/open_webui/models/access_grants.py
Normal file
776
backend/open_webui/models/access_grants.py
Normal file
|
|
@ -0,0 +1,776 @@
|
|||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db_context
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
####################
|
||||
# AccessGrant DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class AccessGrant(Base):
|
||||
__tablename__ = "access_grant"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
|
||||
resource_id = Column(Text, nullable=False)
|
||||
principal_type = Column(Text, nullable=False) # "user" or "group"
|
||||
principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public)
|
||||
permission = Column(Text, nullable=False) # "read" or "write"
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission",
|
||||
name="uq_access_grant_grant",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AccessGrantModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
principal_type: str
|
||||
principal_id: str
|
||||
permission: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class AccessGrantResponse(BaseModel):
|
||||
"""Slim grant model for API responses — resource context is implicit from the parent."""
|
||||
|
||||
id: str
|
||||
principal_type: str
|
||||
principal_id: str
|
||||
permission: str
|
||||
|
||||
@classmethod
|
||||
def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse":
|
||||
return cls(
|
||||
id=grant.id,
|
||||
principal_type=grant.principal_type,
|
||||
principal_id=grant.principal_id,
|
||||
permission=grant.permission,
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# Conversion utilities
|
||||
####################
|
||||
|
||||
|
||||
def access_control_to_grants(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
access_control: Optional[dict],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Convert an old-style access_control JSON dict to a flat list of grant dicts.
|
||||
|
||||
Semantics:
|
||||
- None → public read (user:* read) — except files which are private
|
||||
- {} → private/owner-only (no grants)
|
||||
- {read: {group_ids, user_ids}, write: {group_ids, user_ids}} → specific grants
|
||||
|
||||
Returns a list of dicts with keys: resource_type, resource_id, principal_type, principal_id, permission
|
||||
"""
|
||||
grants = []
|
||||
|
||||
if access_control is None:
|
||||
# NULL → public read (user:* for read)
|
||||
# Exception: files with NULL are private (owner-only), no grants needed
|
||||
if resource_type != "file":
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": "*",
|
||||
"permission": "read",
|
||||
}
|
||||
)
|
||||
return grants
|
||||
|
||||
# {} → private/owner-only, no grants
|
||||
if not access_control:
|
||||
return grants
|
||||
|
||||
# Parse structured permissions
|
||||
for permission in ["read", "write"]:
|
||||
perm_data = access_control.get(permission, {})
|
||||
if not perm_data:
|
||||
continue
|
||||
|
||||
for group_id in perm_data.get("group_ids", []):
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "group",
|
||||
"principal_id": group_id,
|
||||
"permission": permission,
|
||||
}
|
||||
)
|
||||
|
||||
for user_id in perm_data.get("user_ids", []):
|
||||
grants.append(
|
||||
{
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"principal_type": "user",
|
||||
"principal_id": user_id,
|
||||
"permission": permission,
|
||||
}
|
||||
)
|
||||
|
||||
return grants
|
||||
|
||||
|
||||
def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
|
||||
"""
|
||||
Normalize direct access_grants payloads from API forms.
|
||||
|
||||
Keeps only valid grants and removes duplicates by
|
||||
(principal_type, principal_id, permission).
|
||||
"""
|
||||
if not access_grants:
|
||||
return []
|
||||
|
||||
deduped = {}
|
||||
for grant in access_grants:
|
||||
if isinstance(grant, BaseModel):
|
||||
grant = grant.model_dump()
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
|
||||
principal_type = grant.get("principal_type")
|
||||
principal_id = grant.get("principal_id")
|
||||
permission = grant.get("permission")
|
||||
|
||||
if principal_type not in ("user", "group"):
|
||||
continue
|
||||
if permission not in ("read", "write"):
|
||||
continue
|
||||
if not isinstance(principal_id, str) or not principal_id:
|
||||
continue
|
||||
|
||||
key = (principal_type, principal_id, permission)
|
||||
deduped[key] = {
|
||||
"id": grant.get("id")
|
||||
if isinstance(grant.get("id"), str) and grant.get("id")
|
||||
else str(uuid.uuid4()),
|
||||
"principal_type": principal_type,
|
||||
"principal_id": principal_id,
|
||||
"permission": permission,
|
||||
}
|
||||
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
|
||||
"""
|
||||
Returns True when a direct grant list includes wildcard public-read.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if (
|
||||
grant["principal_type"] == "user"
|
||||
and grant["principal_id"] == "*"
|
||||
and grant["permission"] == "read"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def grants_to_access_control(grants: list) -> Optional[dict]:
|
||||
"""
|
||||
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
|
||||
back to the old-style access_control JSON dict for backward compatibility.
|
||||
|
||||
Semantics:
|
||||
- [] (empty) → {} (private/owner-only)
|
||||
- Contains user:*:read → None (public), but write grants are preserved
|
||||
- Otherwise → {read: {group_ids, user_ids}, write: {group_ids, user_ids}}
|
||||
|
||||
Note: "public" (user:*:read) still allows additional write permissions
|
||||
to coexist. When the wildcard read is present the function returns None
|
||||
for the legacy dict, so callers that need write info should inspect the
|
||||
grants list directly.
|
||||
"""
|
||||
if not grants:
|
||||
return {} # No grants = private/owner-only
|
||||
|
||||
result = {
|
||||
"read": {"group_ids": [], "user_ids": []},
|
||||
"write": {"group_ids": [], "user_ids": []},
|
||||
}
|
||||
|
||||
is_public = False
|
||||
for grant in grants:
|
||||
if (
|
||||
grant.principal_type == "user"
|
||||
and grant.principal_id == "*"
|
||||
and grant.permission == "read"
|
||||
):
|
||||
is_public = True
|
||||
continue # Don't add wildcard to user_ids list
|
||||
|
||||
if grant.permission not in ("read", "write"):
|
||||
continue
|
||||
|
||||
if grant.principal_type == "group":
|
||||
if grant.principal_id not in result[grant.permission]["group_ids"]:
|
||||
result[grant.permission]["group_ids"].append(grant.principal_id)
|
||||
elif grant.principal_type == "user":
|
||||
if grant.principal_id not in result[grant.permission]["user_ids"]:
|
||||
result[grant.permission]["user_ids"].append(grant.principal_id)
|
||||
|
||||
if is_public:
|
||||
return None # Public read access
|
||||
|
||||
return result
|
||||
|
||||
|
||||
####################
|
||||
# Table Operations
|
||||
####################
|
||||
|
||||
|
||||
class AccessGrantsTable:
|
||||
def grant_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
principal_type: str,
|
||||
principal_id: str,
|
||||
permission: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[AccessGrantModel]:
|
||||
"""Add a single access grant. Idempotent (ignores duplicates)."""
|
||||
with get_db_context(db) as db:
|
||||
# Check for existing grant
|
||||
existing = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_id,
|
||||
permission=permission,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return AccessGrantModel.model_validate(existing)
|
||||
|
||||
grant = AccessGrant(
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_id,
|
||||
permission=permission,
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(grant)
|
||||
db.commit()
|
||||
db.refresh(grant)
|
||||
return AccessGrantModel.model_validate(grant)
|
||||
|
||||
def revoke_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
principal_type: str,
|
||||
principal_id: str,
|
||||
permission: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
"""Remove a single access grant."""
|
||||
with get_db_context(db) as db:
|
||||
deleted = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=principal_type,
|
||||
principal_id=principal_id,
|
||||
permission=permission,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
db.commit()
|
||||
return deleted > 0
|
||||
|
||||
def revoke_all_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> int:
|
||||
"""Remove all access grants for a resource."""
|
||||
with get_db_context(db) as db:
|
||||
deleted = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
db.commit()
|
||||
return deleted
|
||||
|
||||
def set_access_control(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
access_control: Optional[dict],
|
||||
db: Optional[Session] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""
|
||||
Replace all grants for a resource from an access_control JSON dict.
|
||||
This is the primary bridge for backward compat with the frontend.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
# Delete all existing grants for this resource
|
||||
db.query(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
).delete()
|
||||
|
||||
# Convert JSON to grant dicts
|
||||
grant_dicts = access_control_to_grants(
|
||||
resource_type, resource_id, access_control
|
||||
)
|
||||
|
||||
# Insert new grants
|
||||
results = []
|
||||
for grant_dict in grant_dicts:
|
||||
grant = AccessGrant(
|
||||
id=str(uuid.uuid4()),
|
||||
**grant_dict,
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(grant)
|
||||
results.append(grant)
|
||||
|
||||
db.commit()
|
||||
|
||||
return [AccessGrantModel.model_validate(g) for g in results]
|
||||
|
||||
def set_access_grants(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
access_grants: Optional[list],
|
||||
db: Optional[Session] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""
|
||||
Replace all grants for a resource from a direct access_grants list.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
db.query(AccessGrant).filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
).delete()
|
||||
|
||||
normalized_grants = normalize_access_grants(access_grants)
|
||||
|
||||
results = []
|
||||
for grant_dict in normalized_grants:
|
||||
grant = AccessGrant(
|
||||
id=grant_dict["id"],
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=grant_dict["principal_type"],
|
||||
principal_id=grant_dict["principal_id"],
|
||||
permission=grant_dict["permission"],
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
db.add(grant)
|
||||
results.append(grant)
|
||||
|
||||
db.commit()
|
||||
return [AccessGrantModel.model_validate(g) for g in results]
|
||||
|
||||
def get_access_control(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Reconstruct the old-style access_control JSON dict from grants.
|
||||
For backward compat with the frontend.
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grant_models = [AccessGrantModel.model_validate(g) for g in grants]
|
||||
return grants_to_access_control(grant_models)
|
||||
|
||||
def get_grants_by_resource(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[AccessGrantModel]:
|
||||
"""Get all grants for a specific resource."""
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [AccessGrantModel.model_validate(g) for g in grants]
|
||||
|
||||
def has_access(
|
||||
self,
|
||||
user_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
permission: str = "read",
|
||||
user_group_ids: Optional[set[str]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has the specified permission on a resource.
|
||||
|
||||
Access is granted if any of the following is true:
|
||||
- There's a grant for user:* (public) with the requested permission
|
||||
- There's a grant for the specific user with the requested permission
|
||||
- There's a grant for any of the user's groups with the requested permission
|
||||
"""
|
||||
with get_db_context(db) as db:
|
||||
# Build conditions for matching grants
|
||||
conditions = [
|
||||
# Public access
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
),
|
||||
# Direct user access
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
),
|
||||
]
|
||||
|
||||
# Group access
|
||||
if user_group_ids is None:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
if user_group_ids:
|
||||
conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(user_group_ids),
|
||||
)
|
||||
)
|
||||
|
||||
exists = (
|
||||
db.query(AccessGrant)
|
||||
.filter(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == resource_id,
|
||||
AccessGrant.permission == permission,
|
||||
or_(*conditions),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return exists is not None
|
||||
|
||||
def get_users_with_access(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
permission: str = "read",
|
||||
db: Optional[Session] = None,
|
||||
) -> list:
|
||||
"""
|
||||
Get all users who have the specified permission on a resource.
|
||||
Returns a list of UserModel instances.
|
||||
"""
|
||||
from open_webui.models.users import Users, UserModel
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter_by(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
permission=permission,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Check for public access
|
||||
for grant in grants:
|
||||
if grant.principal_type == "user" and grant.principal_id == "*":
|
||||
result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
|
||||
return result.get("users", [])
|
||||
|
||||
user_ids_with_access = set()
|
||||
|
||||
for grant in grants:
|
||||
if grant.principal_type == "user":
|
||||
user_ids_with_access.add(grant.principal_id)
|
||||
elif grant.principal_type == "group":
|
||||
group_user_ids = Groups.get_group_user_ids_by_id(
|
||||
grant.principal_id, db=db
|
||||
)
|
||||
if group_user_ids:
|
||||
user_ids_with_access.update(group_user_ids)
|
||||
|
||||
if not user_ids_with_access:
|
||||
return []
|
||||
|
||||
return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
|
||||
|
||||
def has_permission_filter(
|
||||
self,
|
||||
db,
|
||||
query,
|
||||
DocumentModel,
|
||||
filter: dict,
|
||||
resource_type: str,
|
||||
permission: str = "read",
|
||||
):
|
||||
"""
|
||||
Apply access control filtering to a SQLAlchemy query by JOINing with access_grant.
|
||||
|
||||
This replaces the old JSON-column-based filtering with a proper relational JOIN.
|
||||
"""
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
if permission == "read_only":
|
||||
return self._has_read_only_permission_filter(
|
||||
db, query, DocumentModel, filter, resource_type
|
||||
)
|
||||
|
||||
# Build principal conditions
|
||||
principal_conditions = []
|
||||
|
||||
if group_ids or user_id:
|
||||
# Public access: user:* read
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
# Owner always has access
|
||||
principal_conditions.append(DocumentModel.user_id == user_id)
|
||||
|
||||
# Direct user grant
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
if group_ids:
|
||||
# Group grants
|
||||
principal_conditions.append(
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
)
|
||||
|
||||
if not principal_conditions:
|
||||
return query
|
||||
|
||||
# LEFT JOIN access_grant and filter
|
||||
# We use a subquery approach to avoid duplicates from multiple matching grants
|
||||
from sqlalchemy import exists as sa_exists, select
|
||||
|
||||
grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == permission,
|
||||
or_(
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
if user_id
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
if group_ids
|
||||
else []
|
||||
),
|
||||
),
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Owner OR has a matching grant
|
||||
owner_or_grant = [grant_exists]
|
||||
if user_id:
|
||||
owner_or_grant.append(DocumentModel.user_id == user_id)
|
||||
|
||||
query = query.filter(or_(*owner_or_grant))
|
||||
return query
|
||||
|
||||
def _has_read_only_permission_filter(
|
||||
self,
|
||||
db,
|
||||
query,
|
||||
DocumentModel,
|
||||
filter: dict,
|
||||
resource_type: str,
|
||||
):
|
||||
"""
|
||||
Filter for items where user has read BUT NOT write access.
|
||||
Public items are NOT considered read_only.
|
||||
"""
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
from sqlalchemy import exists as sa_exists, select
|
||||
|
||||
# Has read grant (not public)
|
||||
read_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "read",
|
||||
or_(
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
if user_id
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
if group_ids
|
||||
else []
|
||||
),
|
||||
),
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Does NOT have write grant
|
||||
write_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "write",
|
||||
or_(
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == user_id,
|
||||
)
|
||||
]
|
||||
if user_id
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[
|
||||
and_(
|
||||
AccessGrant.principal_type == "group",
|
||||
AccessGrant.principal_id.in_(group_ids),
|
||||
)
|
||||
]
|
||||
if group_ids
|
||||
else []
|
||||
),
|
||||
),
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# Is NOT public
|
||||
public_grant_exists = (
|
||||
select(AccessGrant.id)
|
||||
.where(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id == DocumentModel.id,
|
||||
AccessGrant.permission == "read",
|
||||
AccessGrant.principal_type == "user",
|
||||
AccessGrant.principal_id == "*",
|
||||
)
|
||||
.correlate(DocumentModel)
|
||||
.exists()
|
||||
)
|
||||
|
||||
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
|
||||
|
||||
# Not owner
|
||||
if user_id:
|
||||
conditions.append(DocumentModel.user_id != user_id)
|
||||
|
||||
query = query.filter(and_(*conditions))
|
||||
return query
|
||||
|
||||
|
||||
AccessGrants = AccessGrantsTable()
|
||||
|
|
@ -7,8 +7,12 @@ from typing import Optional
|
|||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import (
|
||||
AccessGrantModel,
|
||||
AccessGrants,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
|
|
@ -47,7 +51,6 @@ class Channel(Base):
|
|||
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
|
|
@ -76,7 +79,7 @@ class ChannelModel(BaseModel):
|
|||
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
created_at: int # timestamp in epoch (time_ns)
|
||||
|
||||
|
|
@ -237,7 +240,7 @@ class ChannelForm(BaseModel):
|
|||
is_private: Optional[bool] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
group_ids: Optional[list[str]] = None
|
||||
user_ids: Optional[list[str]] = None
|
||||
|
||||
|
|
@ -252,6 +255,18 @@ class ChannelWebhookForm(BaseModel):
|
|||
|
||||
|
||||
class ChannelTable:
|
||||
def _get_access_grants(
|
||||
self, channel_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("channel", channel_id, db=db)
|
||||
|
||||
def _to_channel_model(
|
||||
self, channel: Channel, db: Optional[Session] = None
|
||||
) -> ChannelModel:
|
||||
channel_data = ChannelModel.model_validate(channel).model_dump(exclude={"access_grants"})
|
||||
access_grants = self._get_access_grants(channel_data["id"], db=db)
|
||||
channel_data["access_grants"] = access_grants
|
||||
return ChannelModel.model_validate(channel_data)
|
||||
|
||||
def _collect_unique_user_ids(
|
||||
self,
|
||||
|
|
@ -316,16 +331,17 @@ class ChannelTable:
|
|||
with get_db_context(db) as db:
|
||||
channel = ChannelModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"type": form_data.type if form_data.type else None,
|
||||
"name": form_data.name.lower(),
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time_ns()),
|
||||
"updated_at": int(time.time_ns()),
|
||||
"access_grants": [],
|
||||
}
|
||||
)
|
||||
new_channel = Channel(**channel.model_dump())
|
||||
new_channel = Channel(**channel.model_dump(exclude={"access_grants"}))
|
||||
|
||||
if form_data.type in ["group", "dm"]:
|
||||
users = self._collect_unique_user_ids(
|
||||
|
|
@ -342,54 +358,25 @@ class ChannelTable:
|
|||
db.add_all(memberships)
|
||||
db.add(new_channel)
|
||||
db.commit()
|
||||
return channel
|
||||
AccessGrants.set_access_grants(
|
||||
"channel", new_channel.id, form_data.access_grants, db=db
|
||||
)
|
||||
return self._to_channel_model(new_channel, db=db)
|
||||
|
||||
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
|
||||
with get_db_context(db) as db:
|
||||
channels = db.query(Channel).all()
|
||||
return [ChannelModel.model_validate(channel) for channel in channels]
|
||||
return [self._to_channel_model(channel, db=db) for channel in channels]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
# Public access
|
||||
conditions = []
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
Channel.access_control.is_(None),
|
||||
cast(Channel.access_control, String) == "null",
|
||||
]
|
||||
)
|
||||
|
||||
# User-level permission
|
||||
if user_id:
|
||||
conditions.append(Channel.user_id == user_id)
|
||||
|
||||
# Group-level permission
|
||||
if group_ids:
|
||||
group_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_conditions.append(
|
||||
Channel.access_control[permission]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_conditions.append(
|
||||
cast(
|
||||
Channel.access_control[permission]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
conditions.append(or_(*group_conditions))
|
||||
|
||||
if conditions:
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query
|
||||
return AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Channel,
|
||||
filter=filter,
|
||||
resource_type="channel",
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
def get_channels_by_user_id(
|
||||
self, user_id: str, db: Optional[Session] = None
|
||||
|
|
@ -428,7 +415,7 @@ class ChannelTable:
|
|||
standard_channels = query.all()
|
||||
|
||||
all_channels = membership_channels + standard_channels
|
||||
return [ChannelModel.model_validate(c) for c in all_channels]
|
||||
return [self._to_channel_model(c, db=db) for c in all_channels]
|
||||
|
||||
def get_dm_channel_by_user_ids(
|
||||
self, user_ids: list[str], db: Optional[Session] = None
|
||||
|
|
@ -463,7 +450,7 @@ class ChannelTable:
|
|||
.first()
|
||||
)
|
||||
|
||||
return ChannelModel.model_validate(channel) if channel else None
|
||||
return self._to_channel_model(channel, db=db) if channel else None
|
||||
|
||||
def add_members_to_channel(
|
||||
self,
|
||||
|
|
@ -722,7 +709,7 @@ class ChannelTable:
|
|||
try:
|
||||
with get_db_context(db) as db:
|
||||
channel = db.query(Channel).filter(Channel.id == id).first()
|
||||
return ChannelModel.model_validate(channel) if channel else None
|
||||
return self._to_channel_model(channel, db=db) if channel else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -735,7 +722,7 @@ class ChannelTable:
|
|||
)
|
||||
channel_ids = [cf.channel_id for cf in channel_files]
|
||||
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
|
||||
return [ChannelModel.model_validate(channel) for channel in channels]
|
||||
return [self._to_channel_model(channel, db=db) for channel in channels]
|
||||
|
||||
def get_channels_by_file_id_and_user_id(
|
||||
self, file_id: str, user_id: str, db: Optional[Session] = None
|
||||
|
|
@ -783,7 +770,9 @@ class ChannelTable:
|
|||
.first()
|
||||
)
|
||||
if membership:
|
||||
allowed_channels.append(ChannelModel.model_validate(channel))
|
||||
allowed_channels.append(
|
||||
self._to_channel_model(channel, db=db)
|
||||
)
|
||||
continue
|
||||
|
||||
# --- Case B: standard channel => rely on ACL permissions ---
|
||||
|
|
@ -798,7 +787,7 @@ class ChannelTable:
|
|||
|
||||
allowed = query.first()
|
||||
if allowed:
|
||||
allowed_channels.append(ChannelModel.model_validate(allowed))
|
||||
allowed_channels.append(self._to_channel_model(allowed, db=db))
|
||||
|
||||
return allowed_channels
|
||||
|
||||
|
|
@ -832,7 +821,7 @@ class ChannelTable:
|
|||
.first()
|
||||
)
|
||||
if membership:
|
||||
return ChannelModel.model_validate(channel)
|
||||
return self._to_channel_model(channel, db=db)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
|
@ -854,7 +843,7 @@ class ChannelTable:
|
|||
|
||||
channel_allowed = query.first()
|
||||
return (
|
||||
ChannelModel.model_validate(channel_allowed)
|
||||
self._to_channel_model(channel_allowed, db=db)
|
||||
if channel_allowed
|
||||
else None
|
||||
)
|
||||
|
|
@ -874,11 +863,14 @@ class ChannelTable:
|
|||
channel.data = form_data.data
|
||||
channel.meta = form_data.meta
|
||||
|
||||
channel.access_control = form_data.access_control
|
||||
if form_data.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"channel", id, form_data.access_grants, db=db
|
||||
)
|
||||
channel.updated_at = int(time.time_ns())
|
||||
|
||||
db.commit()
|
||||
return ChannelModel.model_validate(channel) if channel else None
|
||||
return self._to_channel_model(channel, db=db) if channel else None
|
||||
|
||||
def add_file_to_channel_by_id(
|
||||
self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None
|
||||
|
|
@ -947,6 +939,7 @@ class ChannelTable:
|
|||
|
||||
def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("channel", id, db=db)
|
||||
db.query(Channel).filter(Channel.id == id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ class File(Base):
|
|||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
|
|
@ -45,8 +43,6 @@ class FileModel(BaseModel):
|
|||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
created_at: Optional[int] # timestamp in epoch
|
||||
updated_at: Optional[int] # timestamp in epoch
|
||||
|
||||
|
|
@ -113,7 +109,6 @@ class FileForm(BaseModel):
|
|||
path: str
|
||||
data: dict = {}
|
||||
meta: dict = {}
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class FileUpdateForm(BaseModel):
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from sqlalchemy import (
|
|||
ForeignKey,
|
||||
cast,
|
||||
or_,
|
||||
select,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -99,6 +100,16 @@ class GroupResponse(GroupModel):
|
|||
member_count: Optional[int] = None
|
||||
|
||||
|
||||
class GroupInfoResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str
|
||||
member_count: Optional[int] = None
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class GroupForm(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
|
|
@ -171,22 +182,22 @@ class GroupTable:
|
|||
if share_value:
|
||||
# Groups open to anyone: data is null, config.share is null, or share is true
|
||||
# Use case-insensitive string comparison to handle variations like "True", "TRUE"
|
||||
# Handle potential JSON boolean to string casting issues by checking for both string 'true' and boolean equivalence if possible,
|
||||
anyone_can_share = or_(
|
||||
Group.data.is_(None),
|
||||
json_share_str.is_(None),
|
||||
json_share_lower == "true",
|
||||
json_share_lower == "1", # Handle SQLite boolean true
|
||||
)
|
||||
|
||||
if member_id:
|
||||
# Also include member-only groups where user is a member
|
||||
member_groups_subq = (
|
||||
db.query(GroupMember.group_id)
|
||||
.filter(GroupMember.user_id == member_id)
|
||||
.subquery()
|
||||
member_groups_select = select(GroupMember.group_id).where(
|
||||
GroupMember.user_id == member_id
|
||||
)
|
||||
members_only_and_is_member = and_(
|
||||
json_share_lower == "members",
|
||||
Group.id.in_(member_groups_subq),
|
||||
Group.id.in_(member_groups_select),
|
||||
)
|
||||
query = query.filter(
|
||||
or_(anyone_can_share, members_only_and_is_member)
|
||||
|
|
@ -305,14 +316,14 @@ class GroupTable:
|
|||
|
||||
def get_group_user_ids_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[list[str]]:
|
||||
) -> list[str]:
|
||||
with get_db_context(db) as db:
|
||||
members = (
|
||||
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
|
||||
)
|
||||
|
||||
if not members:
|
||||
return None
|
||||
return []
|
||||
|
||||
return [m[0] for m in members]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ from open_webui.models.files import (
|
|||
)
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Column,
|
||||
|
|
@ -29,9 +30,6 @@ from sqlalchemy import (
|
|||
or_,
|
||||
)
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.db.access_control import has_permission
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -50,22 +48,6 @@ class Knowledge(Base):
|
|||
description = Column(Text)
|
||||
|
||||
meta = Column(JSON, nullable=True)
|
||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||
# Defines access control rules for this entry.
|
||||
# - `None`: Public access, available to all users with the "user" role.
|
||||
# - `{}`: Private access, restricted exclusively to the owner.
|
||||
# - Custom permissions: Specific access control for reading and writing;
|
||||
# Can specify group or user-level restrictions:
|
||||
# {
|
||||
# "read": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# },
|
||||
# "write": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# }
|
||||
# }
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
|
@ -82,7 +64,7 @@ class KnowledgeModel(BaseModel):
|
|||
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
|
@ -139,7 +121,7 @@ class KnowledgeUserResponse(KnowledgeUserModel):
|
|||
class KnowledgeForm(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class FileUserResponse(FileModelResponse):
|
||||
|
|
@ -157,27 +139,47 @@ class KnowledgeFileListResponse(BaseModel):
|
|||
|
||||
|
||||
class KnowledgeTable:
|
||||
def _get_access_grants(
|
||||
self, knowledge_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
|
||||
|
||||
def _to_knowledge_model(
|
||||
self, knowledge: Knowledge, db: Optional[Session] = None
|
||||
) -> KnowledgeModel:
|
||||
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
knowledge_data["access_grants"] = self._get_access_grants(
|
||||
knowledge_data["id"], db=db
|
||||
)
|
||||
return KnowledgeModel.model_validate(knowledge_data)
|
||||
|
||||
def insert_new_knowledge(
|
||||
self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None
|
||||
) -> Optional[KnowledgeModel]:
|
||||
with get_db_context(db) as db:
|
||||
knowledge = KnowledgeModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
"access_grants": [],
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
result = Knowledge(**knowledge.model_dump())
|
||||
result = Knowledge(**knowledge.model_dump(exclude={"access_grants"}))
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"knowledge", result.id, form_data.access_grants, db=db
|
||||
)
|
||||
if result:
|
||||
return KnowledgeModel.model_validate(result)
|
||||
return self._to_knowledge_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
|
|
@ -201,7 +203,7 @@ class KnowledgeTable:
|
|||
knowledge_bases.append(
|
||||
KnowledgeUserModel.model_validate(
|
||||
{
|
||||
**KnowledgeModel.model_validate(knowledge).model_dump(),
|
||||
**self._to_knowledge_model(knowledge, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
|
|
@ -241,7 +243,14 @@ class KnowledgeTable:
|
|||
elif view_option == "shared":
|
||||
query = query.filter(Knowledge.user_id != user_id)
|
||||
|
||||
query = has_permission(db, Knowledge, query, filter)
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Knowledge,
|
||||
filter=filter,
|
||||
resource_type="knowledge",
|
||||
permission="read",
|
||||
)
|
||||
|
||||
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
|
||||
|
||||
|
|
@ -258,8 +267,8 @@ class KnowledgeTable:
|
|||
knowledge_bases.append(
|
||||
KnowledgeUserModel.model_validate(
|
||||
{
|
||||
**KnowledgeModel.model_validate(
|
||||
knowledge_base
|
||||
**self._to_knowledge_model(
|
||||
knowledge_base, db=db
|
||||
).model_dump(),
|
||||
"user": (
|
||||
UserModel.model_validate(user).model_dump()
|
||||
|
|
@ -294,7 +303,14 @@ class KnowledgeTable:
|
|||
|
||||
# Apply access-control directly to the joined query
|
||||
# This makes the database handle filtering, even with 10k+ KBs
|
||||
query = has_permission(db, Knowledge, query, filter)
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Knowledge,
|
||||
filter=filter,
|
||||
resource_type="knowledge",
|
||||
permission="read",
|
||||
)
|
||||
|
||||
# Apply filename search
|
||||
if filter:
|
||||
|
|
@ -327,8 +343,8 @@ class KnowledgeTable:
|
|||
if user
|
||||
else None
|
||||
),
|
||||
collection=KnowledgeModel.model_validate(
|
||||
knowledge
|
||||
collection=self._to_knowledge_model(
|
||||
knowledge, db=db
|
||||
).model_dump(),
|
||||
)
|
||||
)
|
||||
|
|
@ -350,7 +366,14 @@ class KnowledgeTable:
|
|||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||
}
|
||||
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
|
||||
return AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
|
||||
def get_knowledge_bases_by_user_id(
|
||||
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||
|
|
@ -363,8 +386,13 @@ class KnowledgeTable:
|
|||
knowledge_base
|
||||
for knowledge_base in knowledge_bases
|
||||
if knowledge_base.user_id == user_id
|
||||
or has_access(
|
||||
user_id, permission, knowledge_base.access_control, user_group_ids
|
||||
or 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,
|
||||
)
|
||||
]
|
||||
|
||||
|
|
@ -374,7 +402,9 @@ class KnowledgeTable:
|
|||
try:
|
||||
with get_db_context(db) as db:
|
||||
knowledge = db.query(Knowledge).filter_by(id=id).first()
|
||||
return KnowledgeModel.model_validate(knowledge) if knowledge else None
|
||||
return (
|
||||
self._to_knowledge_model(knowledge, db=db) if knowledge else None
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -391,7 +421,14 @@ class KnowledgeTable:
|
|||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||
}
|
||||
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
|
||||
if AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
return knowledge
|
||||
return None
|
||||
|
||||
|
|
@ -406,9 +443,7 @@ class KnowledgeTable:
|
|||
.filter(KnowledgeFile.file_id == file_id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
KnowledgeModel.model_validate(knowledge) for knowledge in knowledges
|
||||
]
|
||||
return [self._to_knowledge_model(knowledge, db=db) for knowledge in knowledges]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
|
@ -591,11 +626,15 @@ class KnowledgeTable:
|
|||
knowledge = self.get_knowledge_by_id(id=id, db=db)
|
||||
db.query(Knowledge).filter_by(id=id).update(
|
||||
{
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
if form_data.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"knowledge", id, form_data.access_grants, db=db
|
||||
)
|
||||
return self.get_knowledge_by_id(id=id, db=db)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
|
@ -622,6 +661,7 @@ class KnowledgeTable:
|
|||
def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("knowledge", id, db=db)
|
||||
db.query(Knowledge).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
|
@ -631,6 +671,9 @@ class KnowledgeTable:
|
|||
def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
|
||||
with get_db_context(db) as db:
|
||||
try:
|
||||
knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()]
|
||||
for knowledge_id in knowledge_ids:
|
||||
AccessGrants.revoke_all_access("knowledge", knowledge_id, db=db)
|
||||
db.query(Knowledge).delete()
|
||||
db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,18 +7,16 @@ from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
|||
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from sqlalchemy import String, cast, or_, and_, func
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
|
||||
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
from sqlalchemy import BigInteger, Column, Text, Boolean
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -80,23 +78,6 @@ class Model(Base):
|
|||
Holds a JSON encoded blob of metadata, see `ModelMeta`.
|
||||
"""
|
||||
|
||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||
# Defines access control rules for this entry.
|
||||
# - `None`: Public access, available to all users with the "user" role.
|
||||
# - `{}`: Private access, restricted exclusively to the owner.
|
||||
# - Custom permissions: Specific access control for reading and writing;
|
||||
# Can specify group or user-level restrictions:
|
||||
# {
|
||||
# "read": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# },
|
||||
# "write": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# }
|
||||
# }
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
updated_at = Column(BigInteger)
|
||||
|
|
@ -112,7 +93,7 @@ class ModelModel(BaseModel):
|
|||
params: ModelParams
|
||||
meta: ModelMeta
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
is_active: bool
|
||||
updated_at: int # timestamp in epoch
|
||||
|
|
@ -154,31 +135,45 @@ class ModelForm(BaseModel):
|
|||
name: str
|
||||
meta: ModelMeta
|
||||
params: ModelParams
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ModelsTable:
|
||||
def _get_access_grants(
|
||||
self, model_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
|
||||
|
||||
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
|
||||
model_data = ModelModel.model_validate(model).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
|
||||
return ModelModel.model_validate(model_data)
|
||||
|
||||
def insert_new_model(
|
||||
self, form_data: ModelForm, user_id: str, db: Optional[Session] = None
|
||||
) -> Optional[ModelModel]:
|
||||
model = ModelModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
result = Model(**model.model_dump())
|
||||
result = Model(
|
||||
**{
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"model", result.id, form_data.access_grants, db=db
|
||||
)
|
||||
|
||||
if result:
|
||||
return ModelModel.model_validate(result)
|
||||
return self._to_model_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
|
|
@ -187,7 +182,7 @@ class ModelsTable:
|
|||
|
||||
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
return [ModelModel.model_validate(model) for model in db.query(Model).all()]
|
||||
return [self._to_model_model(model, db=db) for model in db.query(Model).all()]
|
||||
|
||||
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
|
||||
with get_db_context(db) as db:
|
||||
|
|
@ -204,7 +199,7 @@ class ModelsTable:
|
|||
models.append(
|
||||
ModelUserResponse.model_validate(
|
||||
{
|
||||
**ModelModel.model_validate(model).model_dump(),
|
||||
**self._to_model_model(model, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
|
|
@ -214,7 +209,7 @@ class ModelsTable:
|
|||
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
return [
|
||||
ModelModel.model_validate(model)
|
||||
self._to_model_model(model, db=db)
|
||||
for model in db.query(Model).filter(Model.base_model_id == None).all()
|
||||
]
|
||||
|
||||
|
|
@ -229,50 +224,25 @@ class ModelsTable:
|
|||
model
|
||||
for model in models
|
||||
if model.user_id == user_id
|
||||
or has_access(user_id, permission, model.access_control, user_group_ids)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
# Public access
|
||||
conditions = []
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
Model.access_control.is_(None),
|
||||
cast(Model.access_control, String) == "null",
|
||||
]
|
||||
)
|
||||
|
||||
# User-level permission
|
||||
if user_id:
|
||||
conditions.append(Model.user_id == user_id)
|
||||
|
||||
# Group-level permission
|
||||
if group_ids:
|
||||
group_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_conditions.append(
|
||||
Model.access_control[permission]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_conditions.append(
|
||||
cast(
|
||||
Model.access_control[permission]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
conditions.append(or_(*group_conditions))
|
||||
|
||||
if conditions:
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query
|
||||
return AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Model,
|
||||
filter=filter,
|
||||
resource_type="model",
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
def search_models(
|
||||
self,
|
||||
|
|
@ -358,7 +328,7 @@ class ModelsTable:
|
|||
for model, user in items:
|
||||
models.append(
|
||||
ModelUserResponse(
|
||||
**ModelModel.model_validate(model).model_dump(),
|
||||
**self._to_model_model(model, db=db).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
|
|
@ -375,7 +345,7 @@ class ModelsTable:
|
|||
try:
|
||||
with get_db_context(db) as db:
|
||||
model = db.get(Model, id)
|
||||
return ModelModel.model_validate(model)
|
||||
return self._to_model_model(model, db=db) if model else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -385,7 +355,7 @@ class ModelsTable:
|
|||
try:
|
||||
with get_db_context(db) as db:
|
||||
models = db.query(Model).filter(Model.id.in_(ids)).all()
|
||||
return [ModelModel.model_validate(model) for model in models]
|
||||
return [self._to_model_model(model, db=db) for model in models]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
|
@ -403,7 +373,7 @@ class ModelsTable:
|
|||
db.commit()
|
||||
db.refresh(model)
|
||||
|
||||
return ModelModel.model_validate(model)
|
||||
return self._to_model_model(model, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -413,14 +383,16 @@ class ModelsTable:
|
|||
try:
|
||||
with get_db_context(db) as db:
|
||||
# update only the fields that are present in the model
|
||||
data = model.model_dump(exclude={"id"})
|
||||
data = model.model_dump(exclude={"id", "access_grants"})
|
||||
result = db.query(Model).filter_by(id=id).update(data)
|
||||
|
||||
db.commit()
|
||||
if model.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"model", id, model.access_grants, db=db
|
||||
)
|
||||
|
||||
model = db.get(Model, id)
|
||||
db.refresh(model)
|
||||
return ModelModel.model_validate(model)
|
||||
return self.get_model_by_id(id, db=db)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to update the model by id {id}: {e}")
|
||||
return None
|
||||
|
|
@ -428,6 +400,7 @@ class ModelsTable:
|
|||
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("model", id, db=db)
|
||||
db.query(Model).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
||||
|
|
@ -438,6 +411,9 @@ class ModelsTable:
|
|||
def delete_all_models(self, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
model_ids = [row[0] for row in db.query(Model.id).all()]
|
||||
for model_id in model_ids:
|
||||
AccessGrants.revoke_all_access("model", model_id, db=db)
|
||||
db.query(Model).delete()
|
||||
db.commit()
|
||||
|
||||
|
|
@ -462,7 +438,7 @@ class ModelsTable:
|
|||
if model.id in existing_ids:
|
||||
db.query(Model).filter_by(id=model.id).update(
|
||||
{
|
||||
**model.model_dump(),
|
||||
**model.model_dump(exclude={"access_grants"}),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
|
|
@ -470,22 +446,27 @@ class ModelsTable:
|
|||
else:
|
||||
new_model = Model(
|
||||
**{
|
||||
**model.model_dump(),
|
||||
**model.model_dump(exclude={"access_grants"}),
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(new_model)
|
||||
AccessGrants.set_access_grants(
|
||||
"model", model.id, model.access_grants, db=db
|
||||
)
|
||||
|
||||
# Remove models that are no longer present
|
||||
for model in existing_models:
|
||||
if model.id not in new_model_ids:
|
||||
AccessGrants.revoke_all_access("model", model.id, db=db)
|
||||
db.delete(model)
|
||||
|
||||
db.commit()
|
||||
|
||||
return [
|
||||
ModelModel.model_validate(model) for model in db.query(Model).all()
|
||||
self._to_model_model(model, db=db)
|
||||
for model in db.query(Model).all()
|
||||
]
|
||||
except Exception as e:
|
||||
log.exception(f"Error syncing models for user {user_id}: {e}")
|
||||
|
|
|
|||
|
|
@ -7,17 +7,13 @@ from functools import lru_cache
|
|||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, get_db, get_db_context
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
|
||||
from sqlalchemy.sql import exists
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON
|
||||
from sqlalchemy import or_, func, cast
|
||||
|
||||
####################
|
||||
# Note DB Schema
|
||||
|
|
@ -34,8 +30,6 @@ class Note(Base):
|
|||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
|
|
@ -50,7 +44,7 @@ class NoteModel(BaseModel):
|
|||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
|
@ -65,14 +59,14 @@ class NoteForm(BaseModel):
|
|||
title: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class NoteUpdateForm(BaseModel):
|
||||
title: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class NoteUserResponse(NoteModel):
|
||||
|
|
@ -94,122 +88,25 @@ class NoteListResponse(BaseModel):
|
|||
|
||||
|
||||
class NoteTable:
|
||||
def _get_access_grants(
|
||||
self, note_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
|
||||
|
||||
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
|
||||
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
|
||||
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
|
||||
return NoteModel.model_validate(note_data)
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
conditions = []
|
||||
|
||||
# Handle read_only permission separately
|
||||
if permission == "read_only":
|
||||
# For read_only, we want items where:
|
||||
# 1. User has explicit read permission (via groups or user-level)
|
||||
# 2. BUT does NOT have write permission
|
||||
# 3. Public items are NOT considered read_only
|
||||
|
||||
read_conditions = []
|
||||
|
||||
# Group-level read permission
|
||||
if group_ids:
|
||||
group_read_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_read_conditions.append(
|
||||
Note.access_control["read"]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_read_conditions.append(
|
||||
cast(
|
||||
Note.access_control["read"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_read_conditions:
|
||||
read_conditions.append(or_(*group_read_conditions))
|
||||
|
||||
# Combine read conditions
|
||||
if read_conditions:
|
||||
has_read = or_(*read_conditions)
|
||||
else:
|
||||
# If no read conditions, return empty result
|
||||
return query.filter(False)
|
||||
|
||||
# Now exclude items where user has write permission
|
||||
write_exclusions = []
|
||||
|
||||
# Exclude items owned by user (they have implicit write)
|
||||
if user_id:
|
||||
write_exclusions.append(Note.user_id != user_id)
|
||||
|
||||
# Exclude items where user has explicit write permission via groups
|
||||
if group_ids:
|
||||
group_write_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_write_conditions.append(
|
||||
Note.access_control["write"]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_write_conditions.append(
|
||||
cast(
|
||||
Note.access_control["write"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_write_conditions:
|
||||
# User should NOT have write permission
|
||||
write_exclusions.append(~or_(*group_write_conditions))
|
||||
|
||||
# Exclude public items (items without access_control)
|
||||
write_exclusions.append(Note.access_control.isnot(None))
|
||||
write_exclusions.append(cast(Note.access_control, String) != "null")
|
||||
|
||||
# Combine: has read AND does not have write AND not public
|
||||
if write_exclusions:
|
||||
query = query.filter(and_(has_read, *write_exclusions))
|
||||
else:
|
||||
query = query.filter(has_read)
|
||||
|
||||
return query
|
||||
|
||||
# Original logic for other permissions (read, write, etc.)
|
||||
# Public access conditions
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
Note.access_control.is_(None),
|
||||
cast(Note.access_control, String) == "null",
|
||||
]
|
||||
)
|
||||
|
||||
# User-level permission (owner has all permissions)
|
||||
if user_id:
|
||||
conditions.append(Note.user_id == user_id)
|
||||
|
||||
# Group-level permission
|
||||
if group_ids:
|
||||
group_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_conditions.append(
|
||||
Note.access_control[permission]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_conditions.append(
|
||||
cast(
|
||||
Note.access_control[permission]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
conditions.append(or_(*group_conditions))
|
||||
|
||||
if conditions:
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query
|
||||
return AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Note,
|
||||
filter=filter,
|
||||
resource_type="note",
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
def insert_new_note(
|
||||
self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
|
||||
|
|
@ -219,17 +116,21 @@ class NoteTable:
|
|||
**{
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"created_at": int(time.time_ns()),
|
||||
"updated_at": int(time.time_ns()),
|
||||
"access_grants": [],
|
||||
}
|
||||
)
|
||||
|
||||
new_note = Note(**note.model_dump())
|
||||
new_note = Note(**note.model_dump(exclude={"access_grants"}))
|
||||
|
||||
db.add(new_note)
|
||||
db.commit()
|
||||
return note
|
||||
AccessGrants.set_access_grants(
|
||||
"note", note.id, form_data.access_grants, db=db
|
||||
)
|
||||
return self._to_note_model(new_note, db=db)
|
||||
|
||||
def get_notes(
|
||||
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
|
||||
|
|
@ -241,7 +142,7 @@ class NoteTable:
|
|||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
return [self._to_note_model(note, db=db) for note in notes]
|
||||
|
||||
def search_notes(
|
||||
self,
|
||||
|
|
@ -330,7 +231,7 @@ class NoteTable:
|
|||
for note, user in items:
|
||||
notes.append(
|
||||
NoteUserResponse(
|
||||
**NoteModel.model_validate(note).model_dump(),
|
||||
**self._to_note_model(note, db=db).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
|
|
@ -365,14 +266,14 @@ class NoteTable:
|
|||
query = query.limit(limit)
|
||||
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
return [self._to_note_model(note, db=db) for note in notes]
|
||||
|
||||
def get_note_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
) -> Optional[NoteModel]:
|
||||
with get_db_context(db) as db:
|
||||
note = db.query(Note).filter(Note.id == id).first()
|
||||
return NoteModel.model_validate(note) if note else None
|
||||
return self._to_note_model(note, db=db) if note else None
|
||||
|
||||
def update_note_by_id(
|
||||
self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
|
||||
|
|
@ -391,17 +292,20 @@ class NoteTable:
|
|||
if "meta" in form_data:
|
||||
note.meta = {**note.meta, **form_data["meta"]}
|
||||
|
||||
if "access_control" in form_data:
|
||||
note.access_control = form_data["access_control"]
|
||||
if "access_grants" in form_data:
|
||||
AccessGrants.set_access_grants(
|
||||
"note", id, form_data["access_grants"], db=db
|
||||
)
|
||||
|
||||
note.updated_at = int(time.time_ns())
|
||||
|
||||
db.commit()
|
||||
return NoteModel.model_validate(note) if note else None
|
||||
return self._to_note_model(note, db=db) if note else None
|
||||
|
||||
def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("note", id, db=db)
|
||||
db.query(Note).filter(Note.id == id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class PromptHistoryModel(BaseModel):
|
|||
|
||||
class PromptHistoryResponse(PromptHistoryModel):
|
||||
"""Response model with user info."""
|
||||
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
|
|
@ -91,16 +92,20 @@ class PromptHistoryTable:
|
|||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# Get user info for each entry
|
||||
user_ids = list(set(e.user_id for e in entries))
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
|
||||
|
||||
return [
|
||||
PromptHistoryResponse(
|
||||
**PromptHistoryModel.model_validate(entry).model_dump(),
|
||||
user=users_dict.get(entry.user_id).model_dump() if users_dict.get(entry.user_id) else None,
|
||||
user=(
|
||||
users_dict.get(entry.user_id).model_dump()
|
||||
if users_dict.get(entry.user_id)
|
||||
else None
|
||||
),
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
|
|
@ -112,7 +117,9 @@ class PromptHistoryTable:
|
|||
) -> Optional[PromptHistoryModel]:
|
||||
"""Get a specific history entry by ID."""
|
||||
with get_db_context(db) as db:
|
||||
entry = db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
|
||||
entry = (
|
||||
db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
|
||||
)
|
||||
if entry:
|
||||
return PromptHistoryModel.model_validate(entry)
|
||||
return None
|
||||
|
|
@ -155,27 +162,31 @@ class PromptHistoryTable:
|
|||
) -> Optional[dict]:
|
||||
"""Compute diff between two history entries."""
|
||||
with get_db_context(db) as db:
|
||||
from_entry = db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
|
||||
from_entry = (
|
||||
db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
|
||||
)
|
||||
to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first()
|
||||
|
||||
|
||||
if not from_entry or not to_entry:
|
||||
return None
|
||||
|
||||
|
||||
from_snapshot = from_entry.snapshot
|
||||
to_snapshot = to_entry.snapshot
|
||||
|
||||
|
||||
# Compute diff for content field
|
||||
from_content = from_snapshot.get("content", "")
|
||||
to_content = to_snapshot.get("content", "")
|
||||
|
||||
diff_lines = list(difflib.unified_diff(
|
||||
from_content.splitlines(keepends=True),
|
||||
to_content.splitlines(keepends=True),
|
||||
fromfile=f"v{from_id[:8]}",
|
||||
tofile=f"v{to_id[:8]}",
|
||||
lineterm="",
|
||||
))
|
||||
|
||||
|
||||
diff_lines = list(
|
||||
difflib.unified_diff(
|
||||
from_content.splitlines(keepends=True),
|
||||
to_content.splitlines(keepends=True),
|
||||
fromfile=f"v{from_id[:8]}",
|
||||
tofile=f"v{to_id[:8]}",
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"from_id": from_id,
|
||||
"to_id": to_id,
|
||||
|
|
@ -183,7 +194,6 @@ class PromptHistoryTable:
|
|||
"to_snapshot": to_snapshot,
|
||||
"content_diff": diff_lines,
|
||||
"name_changed": from_snapshot.get("name") != to_snapshot.get("name"),
|
||||
"access_control_changed": from_snapshot.get("access_control") != to_snapshot.get("access_control"),
|
||||
}
|
||||
|
||||
def delete_history_by_prompt_id(
|
||||
|
|
@ -193,7 +203,9 @@ class PromptHistoryTable:
|
|||
) -> bool:
|
||||
"""Delete all history entries for a prompt."""
|
||||
with get_db_context(db) as db:
|
||||
db.query(PromptHistory).filter(PromptHistory.prompt_id == prompt_id).delete()
|
||||
db.query(PromptHistory).filter(
|
||||
PromptHistory.prompt_id == prompt_id
|
||||
).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,13 @@ from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
|||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.prompt_history import PromptHistories
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
|
||||
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
|
||||
####################
|
||||
# Prompts DB Schema
|
||||
####################
|
||||
|
|
@ -37,23 +35,6 @@ class Prompt(Base):
|
|||
created_at = Column(BigInteger, nullable=True)
|
||||
updated_at = Column(BigInteger, nullable=True)
|
||||
|
||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||
# Defines access control rules for this entry.
|
||||
# - `None`: Public access, available to all users with the "user" role.
|
||||
# - `{}`: Private access, restricted exclusively to the owner.
|
||||
# - Custom permissions: Specific access control for reading and writing;
|
||||
# Can specify group or user-level restrictions:
|
||||
# {
|
||||
# "read": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# },
|
||||
# "write": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
class PromptModel(BaseModel):
|
||||
id: Optional[str] = None
|
||||
|
|
@ -68,7 +49,7 @@ class PromptModel(BaseModel):
|
|||
version_id: Optional[str] = None
|
||||
created_at: Optional[int] = None
|
||||
updated_at: Optional[int] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
|
@ -104,13 +85,27 @@ class PromptForm(BaseModel):
|
|||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
tags: Optional[list[str]] = None
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
version_id: Optional[str] = None # Active version
|
||||
commit_message: Optional[str] = None # For history tracking
|
||||
is_production: Optional[bool] = True # Whether to set new version as production
|
||||
|
||||
|
||||
class PromptsTable:
|
||||
def _get_access_grants(
|
||||
self, prompt_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
|
||||
|
||||
def _to_prompt_model(
|
||||
self, prompt: Prompt, db: Optional[Session] = None
|
||||
) -> PromptModel:
|
||||
prompt_data = PromptModel.model_validate(prompt).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
|
||||
return PromptModel.model_validate(prompt_data)
|
||||
|
||||
def insert_new_prompt(
|
||||
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
|
||||
) -> Optional[PromptModel]:
|
||||
|
|
@ -126,7 +121,7 @@ class PromptsTable:
|
|||
data=form_data.data or {},
|
||||
meta=form_data.meta or {},
|
||||
tags=form_data.tags or [],
|
||||
access_control=form_data.access_control,
|
||||
access_grants=[],
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
|
|
@ -134,12 +129,16 @@ class PromptsTable:
|
|||
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
result = Prompt(**prompt.model_dump())
|
||||
result = Prompt(**prompt.model_dump(exclude={"access_grants"}))
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"prompt", prompt_id, form_data.access_grants, db=db
|
||||
)
|
||||
|
||||
if result:
|
||||
current_access_grants = self._get_access_grants(prompt_id, db=db)
|
||||
snapshot = {
|
||||
"name": form_data.name,
|
||||
"content": form_data.content,
|
||||
|
|
@ -147,7 +146,7 @@ class PromptsTable:
|
|||
"data": form_data.data or {},
|
||||
"meta": form_data.meta or {},
|
||||
"tags": form_data.tags or [],
|
||||
"access_control": form_data.access_control,
|
||||
"access_grants": [grant.model_dump() for grant in current_access_grants],
|
||||
}
|
||||
|
||||
history_entry = PromptHistories.create_history_entry(
|
||||
|
|
@ -165,7 +164,7 @@ class PromptsTable:
|
|||
db.commit()
|
||||
db.refresh(result)
|
||||
|
||||
return PromptModel.model_validate(result)
|
||||
return self._to_prompt_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
|
|
@ -179,7 +178,7 @@ class PromptsTable:
|
|||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if prompt:
|
||||
return PromptModel.model_validate(prompt)
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -191,7 +190,7 @@ class PromptsTable:
|
|||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||
if prompt:
|
||||
return PromptModel.model_validate(prompt)
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -216,7 +215,7 @@ class PromptsTable:
|
|||
prompts.append(
|
||||
PromptUserResponse.model_validate(
|
||||
{
|
||||
**PromptModel.model_validate(prompt).model_dump(),
|
||||
**self._to_prompt_model(prompt, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
|
|
@ -236,7 +235,14 @@ class PromptsTable:
|
|||
prompt
|
||||
for prompt in prompts
|
||||
if prompt.user_id == user_id
|
||||
or has_access(user_id, permission, prompt.access_control, user_group_ids)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
def search_prompts(
|
||||
|
|
@ -273,17 +279,15 @@ class PromptsTable:
|
|||
elif view_option == "shared":
|
||||
query = query.filter(Prompt.user_id != user_id)
|
||||
|
||||
# Apply access control filtering
|
||||
group_ids = filter.get("group_ids", [])
|
||||
filter_user_id = filter.get("user_id")
|
||||
|
||||
if filter_user_id:
|
||||
# User must have access: owner OR public OR explicit access
|
||||
access_conditions = [
|
||||
Prompt.user_id == filter_user_id, # Owner
|
||||
Prompt.access_control == None, # Public
|
||||
]
|
||||
query = query.filter(or_(*access_conditions))
|
||||
# Apply access grant filtering
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Prompt,
|
||||
filter=filter,
|
||||
resource_type="prompt",
|
||||
permission="read",
|
||||
)
|
||||
|
||||
tag = filter.get("tag")
|
||||
if tag:
|
||||
|
|
@ -329,7 +333,7 @@ class PromptsTable:
|
|||
for prompt, user in items:
|
||||
prompts.append(
|
||||
PromptUserResponse(
|
||||
**PromptModel.model_validate(prompt).model_dump(),
|
||||
**self._to_prompt_model(prompt, db=db).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
|
|
@ -358,12 +362,13 @@ class PromptsTable:
|
|||
prompt.id, db=db
|
||||
)
|
||||
parent_id = latest_history.id if latest_history else None
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
# Check if content changed to decide on history creation
|
||||
content_changed = (
|
||||
prompt.name != form_data.name
|
||||
or prompt.content != form_data.content
|
||||
or prompt.access_control != form_data.access_control
|
||||
or form_data.access_grants is not None
|
||||
)
|
||||
|
||||
# Update prompt fields
|
||||
|
|
@ -371,8 +376,12 @@ class PromptsTable:
|
|||
prompt.content = form_data.content
|
||||
prompt.data = form_data.data or prompt.data
|
||||
prompt.meta = form_data.meta or prompt.meta
|
||||
prompt.access_control = form_data.access_control
|
||||
prompt.updated_at = int(time.time())
|
||||
if form_data.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"prompt", prompt.id, form_data.access_grants, db=db
|
||||
)
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
|
@ -384,7 +393,9 @@ class PromptsTable:
|
|||
"command": command,
|
||||
"data": form_data.data or {},
|
||||
"meta": form_data.meta or {},
|
||||
"access_control": form_data.access_control,
|
||||
"access_grants": [
|
||||
grant.model_dump() for grant in current_access_grants
|
||||
],
|
||||
}
|
||||
|
||||
history_entry = PromptHistories.create_history_entry(
|
||||
|
|
@ -401,7 +412,7 @@ class PromptsTable:
|
|||
prompt.version_id = history_entry.id
|
||||
db.commit()
|
||||
|
||||
return PromptModel.model_validate(prompt)
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -422,13 +433,14 @@ class PromptsTable:
|
|||
prompt.id, db=db
|
||||
)
|
||||
parent_id = latest_history.id if latest_history else None
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
# Check if content changed to decide on history creation
|
||||
content_changed = (
|
||||
prompt.name != form_data.name
|
||||
or prompt.command != form_data.command
|
||||
or prompt.content != form_data.content
|
||||
or prompt.access_control != form_data.access_control
|
||||
or form_data.access_grants is not None
|
||||
or (form_data.tags is not None and prompt.tags != form_data.tags)
|
||||
)
|
||||
|
||||
|
|
@ -438,10 +450,15 @@ class PromptsTable:
|
|||
prompt.content = form_data.content
|
||||
prompt.data = form_data.data or prompt.data
|
||||
prompt.meta = form_data.meta or prompt.meta
|
||||
prompt.access_control = form_data.access_control
|
||||
|
||||
if form_data.tags is not None:
|
||||
prompt.tags = form_data.tags
|
||||
|
||||
if form_data.access_grants is not None:
|
||||
AccessGrants.set_access_grants(
|
||||
"prompt", prompt.id, form_data.access_grants, db=db
|
||||
)
|
||||
current_access_grants = self._get_access_grants(prompt.id, db=db)
|
||||
|
||||
prompt.updated_at = int(time.time())
|
||||
|
||||
|
|
@ -456,7 +473,9 @@ class PromptsTable:
|
|||
"data": form_data.data or {},
|
||||
"meta": form_data.meta or {},
|
||||
"tags": prompt.tags or [],
|
||||
"access_control": form_data.access_control,
|
||||
"access_grants": [
|
||||
grant.model_dump() for grant in current_access_grants
|
||||
],
|
||||
}
|
||||
|
||||
history_entry = PromptHistories.create_history_entry(
|
||||
|
|
@ -473,7 +492,7 @@ class PromptsTable:
|
|||
prompt.version_id = history_entry.id
|
||||
db.commit()
|
||||
|
||||
return PromptModel.model_validate(prompt)
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -501,7 +520,7 @@ class PromptsTable:
|
|||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
|
||||
return PromptModel.model_validate(prompt)
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -533,13 +552,13 @@ class PromptsTable:
|
|||
prompt.data = snapshot.get("data", prompt.data)
|
||||
prompt.meta = snapshot.get("meta", prompt.meta)
|
||||
prompt.tags = snapshot.get("tags", prompt.tags)
|
||||
# Note: command and access_control are not restored from snapshot
|
||||
# Note: command and access_grants are not restored from snapshot
|
||||
|
||||
prompt.version_id = version_id
|
||||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
|
||||
return PromptModel.model_validate(prompt)
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -552,6 +571,7 @@ class PromptsTable:
|
|||
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
prompt.is_active = False
|
||||
prompt.updated_at = int(time.time())
|
||||
|
|
@ -568,6 +588,7 @@ class PromptsTable:
|
|||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
prompt.is_active = False
|
||||
prompt.updated_at = int(time.time())
|
||||
|
|
@ -586,6 +607,7 @@ class PromptsTable:
|
|||
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
# Delete prompt
|
||||
db.query(Prompt).filter_by(command=command).delete()
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ from sqlalchemy.orm import Session
|
|||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Column, String, Text
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -31,23 +30,6 @@ class Tool(Base):
|
|||
meta = Column(JSONField)
|
||||
valves = Column(JSONField)
|
||||
|
||||
access_control = Column(JSON, nullable=True) # Controls data access levels.
|
||||
# Defines access control rules for this entry.
|
||||
# - `None`: Public access, available to all users with the "user" role.
|
||||
# - `{}`: Private access, restricted exclusively to the owner.
|
||||
# - Custom permissions: Specific access control for reading and writing;
|
||||
# Can specify group or user-level restrictions:
|
||||
# {
|
||||
# "read": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# },
|
||||
# "write": {
|
||||
# "group_ids": ["group_id1", "group_id2"],
|
||||
# "user_ids": ["user_id1", "user_id2"]
|
||||
# }
|
||||
# }
|
||||
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
|
|
@ -64,7 +46,7 @@ class ToolModel(BaseModel):
|
|||
content: str
|
||||
specs: list[dict]
|
||||
meta: ToolMeta
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
|
@ -86,7 +68,7 @@ class ToolResponse(BaseModel):
|
|||
user_id: str
|
||||
name: str
|
||||
meta: ToolMeta
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: list[AccessGrantModel] = Field(default_factory=list)
|
||||
updated_at: int # timestamp in epoch
|
||||
created_at: int # timestamp in epoch
|
||||
|
||||
|
|
@ -106,7 +88,7 @@ class ToolForm(BaseModel):
|
|||
name: str
|
||||
content: str
|
||||
meta: ToolMeta
|
||||
access_control: Optional[dict] = None
|
||||
access_grants: Optional[list[dict]] = None
|
||||
|
||||
|
||||
class ToolValves(BaseModel):
|
||||
|
|
@ -114,6 +96,16 @@ class ToolValves(BaseModel):
|
|||
|
||||
|
||||
class ToolsTable:
|
||||
def _get_access_grants(
|
||||
self, tool_id: str, db: Optional[Session] = None
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
|
||||
|
||||
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
|
||||
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
|
||||
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
|
||||
return ToolModel.model_validate(tool_data)
|
||||
|
||||
def insert_new_tool(
|
||||
self,
|
||||
user_id: str,
|
||||
|
|
@ -122,23 +114,24 @@ class ToolsTable:
|
|||
db: Optional[Session] = None,
|
||||
) -> Optional[ToolModel]:
|
||||
with get_db_context(db) as db:
|
||||
tool = ToolModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
"specs": specs,
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
result = Tool(**tool.model_dump())
|
||||
result = Tool(
|
||||
**{
|
||||
**form_data.model_dump(exclude={"access_grants"}),
|
||||
"specs": specs,
|
||||
"user_id": user_id,
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.add(result)
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
AccessGrants.set_access_grants(
|
||||
"tool", result.id, form_data.access_grants, db=db
|
||||
)
|
||||
if result:
|
||||
return ToolModel.model_validate(result)
|
||||
return self._to_tool_model(result, db=db)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
|
|
@ -151,7 +144,7 @@ class ToolsTable:
|
|||
try:
|
||||
with get_db_context(db) as db:
|
||||
tool = db.get(Tool, id)
|
||||
return ToolModel.model_validate(tool)
|
||||
return self._to_tool_model(tool, db=db) if tool else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -170,7 +163,7 @@ class ToolsTable:
|
|||
tools.append(
|
||||
ToolUserModel.model_validate(
|
||||
{
|
||||
**ToolModel.model_validate(tool).model_dump(),
|
||||
**self._to_tool_model(tool, db=db).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
|
|
@ -189,7 +182,14 @@ class ToolsTable:
|
|||
tool
|
||||
for tool in tools
|
||||
if tool.user_id == user_id
|
||||
or has_access(user_id, permission, tool.access_control, user_group_ids)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="tool",
|
||||
resource_id=tool.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
def get_tool_valves_by_id(
|
||||
|
|
@ -266,20 +266,24 @@ class ToolsTable:
|
|||
) -> Optional[ToolModel]:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
access_grants = updated.pop("access_grants", None)
|
||||
db.query(Tool).filter_by(id=id).update(
|
||||
{**updated, "updated_at": int(time.time())}
|
||||
)
|
||||
db.commit()
|
||||
if access_grants is not None:
|
||||
AccessGrants.set_access_grants("tool", id, access_grants, db=db)
|
||||
|
||||
tool = db.query(Tool).get(id)
|
||||
db.refresh(tool)
|
||||
return ToolModel.model_validate(tool)
|
||||
return self._to_tool_model(tool, db=db)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
AccessGrants.revoke_all_access("tool", id, db=db)
|
||||
db.query(Tool).filter_by(id=id).delete()
|
||||
db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,9 @@ class UserInfoResponse(UserStatus):
|
|||
name: str
|
||||
email: str
|
||||
role: str
|
||||
bio: Optional[str] = None
|
||||
groups: Optional[list] = []
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class UserIdNameResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ from open_webui.models.knowledge import Knowledges
|
|||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
from open_webui.retrieval.vector.main import GetResult
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.misc import get_message_list
|
||||
|
||||
|
|
@ -999,7 +999,12 @@ async def get_sources_from_items(
|
|||
if note and (
|
||||
user.role == "admin"
|
||||
or note.user_id == user.id
|
||||
or has_access(user.id, "read", note.access_control)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
# User has access to the note
|
||||
query_result = {
|
||||
|
|
@ -1091,7 +1096,12 @@ async def get_sources_from_items(
|
|||
if knowledge_base and (
|
||||
user.role == "admin"
|
||||
or knowledge_base.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge_base.access_control)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
if (
|
||||
item.get("context") == "full"
|
||||
|
|
@ -1100,7 +1110,12 @@ async def get_sources_from_items(
|
|||
if knowledge_base and (
|
||||
user.role == "admin"
|
||||
or knowledge_base.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge_base.access_control)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
files = Knowledges.get_files_by_id(knowledge_base.id)
|
||||
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ class OpenSearchClient(VectorDBBase):
|
|||
for item in batch
|
||||
]
|
||||
bulk(self.client, actions)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
self.client.indices.refresh(index=self._get_index_name(collection_name))
|
||||
|
||||
def upsert(self, collection_name: str, items: list[VectorItem]):
|
||||
self._create_index_if_not_exists(
|
||||
|
|
@ -234,7 +234,7 @@ class OpenSearchClient(VectorDBBase):
|
|||
for item in batch
|
||||
]
|
||||
bulk(self.client, actions)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
self.client.indices.refresh(index=self._get_index_name(collection_name))
|
||||
|
||||
def delete(
|
||||
self,
|
||||
|
|
@ -263,7 +263,7 @@ class OpenSearchClient(VectorDBBase):
|
|||
self.client.delete_by_query(
|
||||
index=self._get_index_name(collection_name), body=query_body
|
||||
)
|
||||
self.client.indices.refresh(self._get_index_name(collection_name))
|
||||
self.client.indices.refresh(index=self._get_index_name(collection_name))
|
||||
|
||||
def reset(self):
|
||||
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from fastapi import Request
|
|||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -31,7 +32,7 @@ def search_external(
|
|||
|
||||
chat_id = getattr(request.state, "chat_id", None)
|
||||
if chat_id:
|
||||
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
|
||||
|
||||
response = requests.post(
|
||||
external_url,
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ class URLProcessingMixin:
|
|||
|
||||
def _safe_process_url_sync(self, url: str) -> bool:
|
||||
"""Synchronous version of safety checks."""
|
||||
if self.verify_ssl and not self._verify_ssl_cert(url):
|
||||
if self.verify_ssl and not verify_ssl_cert(url):
|
||||
raise ValueError(f"SSL certificate verification failed for {url}")
|
||||
self._sync_wait_for_rate_limit()
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from fastapi import Request
|
|||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
|
||||
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.etree.ElementTree import Element
|
||||
|
|
@ -50,7 +51,7 @@ def search_yandex(
|
|||
|
||||
chat_id = getattr(request.state, "chat_id", None)
|
||||
if chat_id:
|
||||
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
|
||||
|
||||
payload = {} if yandex_search_config == "" else json.loads(yandex_search_config)
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from open_webui.models.channels import (
|
|||
ChannelWebhookModel,
|
||||
ChannelWebhookForm,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.models.messages import (
|
||||
Messages,
|
||||
MessageModel,
|
||||
|
|
@ -60,12 +61,7 @@ from open_webui.utils.chat import generate_chat_completion
|
|||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import (
|
||||
has_access,
|
||||
get_users_with_access,
|
||||
get_permitted_group_and_user_ids,
|
||||
has_permission,
|
||||
)
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from open_webui.utils.channels import extract_mentions, replace_mentions
|
||||
from open_webui.internal.db import get_session
|
||||
|
|
@ -76,6 +72,66 @@ log = logging.getLogger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def channel_has_access(
|
||||
user_id: str,
|
||||
channel: ChannelModel,
|
||||
permission: str = "read",
|
||||
strict: bool = True,
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
if AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="channel",
|
||||
resource_id=channel.id,
|
||||
permission=permission,
|
||||
db=db,
|
||||
):
|
||||
return True
|
||||
|
||||
if (
|
||||
not strict
|
||||
and permission == "write"
|
||||
and has_public_read_access_grant(channel.access_grants)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_channel_users_with_access(
|
||||
channel: ChannelModel, permission: str = "read", db: Optional[Session] = None
|
||||
):
|
||||
return AccessGrants.get_users_with_access(
|
||||
resource_type="channel",
|
||||
resource_id=channel.id,
|
||||
permission=permission,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
def get_channel_permitted_group_and_user_ids(
|
||||
channel: ChannelModel, permission: str = "read"
|
||||
) -> Optional[dict[str, list[str]]]:
|
||||
if permission == "read" and has_public_read_access_grant(channel.access_grants):
|
||||
return None
|
||||
|
||||
user_ids = []
|
||||
group_ids = []
|
||||
|
||||
for grant in channel.access_grants:
|
||||
if grant.permission != permission:
|
||||
continue
|
||||
if grant.principal_type == "group":
|
||||
group_ids.append(grant.principal_id)
|
||||
elif grant.principal_type == "user" and grant.principal_id != "*":
|
||||
user_ids.append(grant.principal_id)
|
||||
|
||||
return {
|
||||
"user_ids": list(dict.fromkeys(user_ids)),
|
||||
"group_ids": list(dict.fromkeys(group_ids)),
|
||||
}
|
||||
|
||||
|
||||
############################
|
||||
# Channels Enabled Dependency
|
||||
############################
|
||||
|
|
@ -418,22 +474,22 @@ async def get_channel_by_id(
|
|||
}
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
write_access = has_access(
|
||||
write_access = channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
)
|
||||
|
||||
user_count = len(get_users_with_access("read", channel.access_control))
|
||||
user_count = len(get_channel_users_with_access(channel, "read", db=db))
|
||||
|
||||
channel_member = Channels.get_member_by_channel_and_user_id(
|
||||
channel.id, user.id, db=db
|
||||
|
|
@ -527,8 +583,8 @@ async def get_channel_members_by_id(
|
|||
filter["channel_id"] = channel.id
|
||||
else:
|
||||
filter["roles"] = ["!pending"]
|
||||
permitted_ids = get_permitted_group_and_user_ids(
|
||||
"read", channel.access_control
|
||||
permitted_ids = get_channel_permitted_group_and_user_ids(
|
||||
channel, permission="read"
|
||||
)
|
||||
if permitted_ids:
|
||||
filter["user_ids"] = permitted_ids.get("user_ids")
|
||||
|
|
@ -811,8 +867,8 @@ async def get_channel_messages(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -888,8 +944,8 @@ async def get_pinned_channel_messages(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -946,7 +1002,7 @@ async def get_pinned_channel_messages(
|
|||
async def send_notification(
|
||||
name, webui_url, channel, message, active_user_ids, db=None
|
||||
):
|
||||
users = get_users_with_access("read", channel.access_control)
|
||||
users = get_channel_users_with_access(channel, "read", db=db)
|
||||
|
||||
for user in users:
|
||||
if (user.id not in active_user_ids) and Channels.is_user_channel_member(
|
||||
|
|
@ -1173,10 +1229,10 @@ async def new_message_handler(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
|
|
@ -1318,8 +1374,8 @@ async def get_channel_message(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -1372,8 +1428,8 @@ async def get_channel_message_data(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -1426,8 +1482,8 @@ async def pin_channel_message(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -1492,8 +1548,8 @@ async def get_channel_thread_messages(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -1577,8 +1633,8 @@ async def update_message_by_id(
|
|||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control, db=db
|
||||
and not channel_has_access(
|
||||
user.id, channel, permission="read", db=db
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -1644,10 +1700,10 @@ async def add_reaction_to_message(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
|
|
@ -1723,10 +1779,10 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
else:
|
||||
if user.role != "admin" and not has_access(
|
||||
if user.role != "admin" and not channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
):
|
||||
|
|
@ -1818,10 +1874,10 @@ async def delete_message_by_id(
|
|||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(
|
||||
and not channel_has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=channel.access_control,
|
||||
channel,
|
||||
permission="write",
|
||||
strict=False,
|
||||
db=db,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from open_webui.models.files import (
|
|||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
|
||||
from open_webui.routers.retrieval import ProcessFileForm, process_file
|
||||
|
|
@ -47,7 +48,6 @@ from open_webui.storage.provider import Storage
|
|||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.misc import strict_match_mime_type
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -82,8 +82,13 @@ def has_access_to_file(
|
|||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.user_id == user.id or has_access(
|
||||
user.id, access_type, knowledge_base.access_control, user_group_ids, db=db
|
||||
if knowledge_base.user_id == user.id or 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
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from open_webui.models.users import Users, UserInfoResponse
|
|||
from open_webui.models.groups import (
|
||||
Groups,
|
||||
GroupForm,
|
||||
GroupInfoResponse,
|
||||
GroupUpdateForm,
|
||||
GroupResponse,
|
||||
UserIdsForm,
|
||||
|
|
@ -104,6 +105,23 @@ async def get_group_by_id(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/id/{id}/info", response_model=Optional[GroupInfoResponse])
|
||||
async def get_group_info_by_id(
|
||||
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
group = Groups.get_group_by_id(id, db=db)
|
||||
if group:
|
||||
return GroupInfoResponse(
|
||||
**group.model_dump(),
|
||||
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ExportGroupById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ from open_webui.storage.provider import Storage
|
|||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_verified_user, get_admin_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
|
@ -133,8 +134,12 @@ async def get_knowledge_bases(
|
|||
write_access=(
|
||||
user.id == knowledge_base.user_id
|
||||
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or has_access(
|
||||
user.id, "write", knowledge_base.access_control, db=db
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -180,8 +185,12 @@ async def search_knowledge_bases(
|
|||
write_access=(
|
||||
user.id == knowledge_base.user_id
|
||||
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or has_access(
|
||||
user.id, "write", knowledge_base.access_control, db=db
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -243,14 +252,14 @@ async def create_new_knowledge(
|
|||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
form_data.access_grants = []
|
||||
|
||||
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
|
||||
|
||||
|
|
@ -387,7 +396,13 @@ async def get_knowledge_by_id(
|
|||
if (
|
||||
user.role == "admin"
|
||||
or knowledge.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
|
||||
return KnowledgeFilesResponse(
|
||||
|
|
@ -395,7 +410,13 @@ async def get_knowledge_by_id(
|
|||
write_access=(
|
||||
user.id == knowledge.user_id
|
||||
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
|
|
@ -435,7 +456,12 @@ async def update_knowledge_by_id(
|
|||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -446,14 +472,14 @@ async def update_knowledge_by_id(
|
|||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
form_data.access_grants = []
|
||||
|
||||
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
|
||||
if knowledge:
|
||||
|
|
@ -502,7 +528,13 @@ async def get_knowledge_files_by_id(
|
|||
if not (
|
||||
user.role == "admin"
|
||||
or knowledge.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -555,7 +587,13 @@ def add_file_to_knowledge_by_id(
|
|||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -624,7 +662,13 @@ def update_file_from_knowledge_by_id(
|
|||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
|
||||
|
|
@ -693,7 +737,13 @@ def remove_file_from_knowledge_by_id(
|
|||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -770,7 +820,13 @@ async def delete_knowledge_by_id(
|
|||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -802,7 +858,7 @@ async def delete_knowledge_by_id(
|
|||
base_model_id=model.base_model_id,
|
||||
meta=model.meta,
|
||||
params=model.params,
|
||||
access_control=model.access_control,
|
||||
access_grants=model.access_grants,
|
||||
is_active=model.is_active,
|
||||
)
|
||||
Models.update_model_by_id(model.id, model_form, db=db)
|
||||
|
|
@ -839,7 +895,13 @@ async def reset_knowledge_by_id(
|
|||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -882,7 +944,13 @@ async def add_files_to_knowledge_batch(
|
|||
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -890,17 +958,19 @@ async def add_files_to_knowledge_batch(
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Get files content
|
||||
# Batch-fetch all files to avoid N+1 queries
|
||||
log.info(f"files/batch/add - {len(form_data)} files")
|
||||
files: List[FileModel] = []
|
||||
for form in form_data:
|
||||
file = Files.get_file_by_id(form.file_id, db=db)
|
||||
if not file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File {form.file_id} not found",
|
||||
)
|
||||
files.append(file)
|
||||
file_ids = [form.file_id for form in form_data]
|
||||
files = Files.get_files_by_ids(file_ids, db=db)
|
||||
|
||||
# Verify all requested files were found
|
||||
found_ids = {file.id for file in files}
|
||||
missing_ids = [fid for fid in file_ids if fid not in found_ids]
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File {missing_ids[0]} not found",
|
||||
)
|
||||
|
||||
# Process files
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from open_webui.models.models import (
|
|||
ModelAccessResponse,
|
||||
Models,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
from pydantic import BaseModel
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
|
@ -30,7 +31,7 @@ from fastapi.responses import FileResponse, StreamingResponse
|
|||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -98,7 +99,13 @@ async def get_models(
|
|||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model.user_id
|
||||
or has_access(user.id, "write", model.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
for model in result.items
|
||||
|
|
@ -315,14 +322,26 @@ async def get_model_by_id(
|
|||
if (
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or model.user_id == user.id
|
||||
or has_access(user.id, "read", model.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return ModelAccessResponse(
|
||||
**model.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model.user_id
|
||||
or has_access(user.id, "write", model.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
|
|
@ -393,7 +412,13 @@ async def toggle_model_by_id(
|
|||
if (
|
||||
user.role == "admin"
|
||||
or model.user_id == user.id
|
||||
or has_access(user.id, "write", model.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
model = Models.toggle_model_by_id(id, db=db)
|
||||
|
||||
|
|
@ -436,7 +461,13 @@ async def update_model_by_id(
|
|||
|
||||
if (
|
||||
model.user_id != user.id
|
||||
and not has_access(user.id, "write", model.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -471,7 +502,13 @@ async def delete_model_by_id(
|
|||
if (
|
||||
user.role != "admin"
|
||||
and model.user_id != user.id
|
||||
and not has_access(user.id, "write", model.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ from open_webui.constants import ERROR_MESSAGES
|
|||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -200,8 +201,12 @@ async def get_note_by_id(
|
|||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and (
|
||||
not has_access(
|
||||
user.id, type="read", access_control=note.access_control, db=db
|
||||
not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
):
|
||||
|
|
@ -212,13 +217,14 @@ async def get_note_by_id(
|
|||
write_access = (
|
||||
user.role == "admin"
|
||||
or (user.id == note.user_id)
|
||||
or has_access(
|
||||
user.id,
|
||||
type="write",
|
||||
access_control=note.access_control,
|
||||
strict=False,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
or has_public_read_access_grant(note.access_grants)
|
||||
)
|
||||
|
||||
return NoteResponse(**note.model_dump(), write_access=write_access)
|
||||
|
|
@ -253,8 +259,12 @@ async def update_note_by_id(
|
|||
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and not has_access(
|
||||
user.id, type="write", access_control=note.access_control, db=db
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -264,7 +274,7 @@ async def update_note_by_id(
|
|||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and form_data.access_control == None
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_notes",
|
||||
|
|
@ -272,7 +282,7 @@ async def update_note_by_id(
|
|||
db=db,
|
||||
)
|
||||
):
|
||||
form_data.access_control = {}
|
||||
form_data.access_grants = []
|
||||
|
||||
try:
|
||||
note = Notes.update_note_by_id(id, form_data, db=db)
|
||||
|
|
@ -318,8 +328,12 @@ async def delete_note_by_id(
|
|||
|
||||
if user.role != "admin" and (
|
||||
user.id != note.user_id
|
||||
and not has_access(
|
||||
user.id, type="write", access_control=note.access_control, db=db
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from open_webui.models.users import UserModel
|
|||
|
||||
from open_webui.env import (
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
)
|
||||
|
||||
from fastapi import (
|
||||
|
|
@ -44,6 +45,7 @@ from open_webui.internal.db import get_session
|
|||
|
||||
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.misc import (
|
||||
calculate_sha256,
|
||||
)
|
||||
|
|
@ -53,9 +55,6 @@ from open_webui.utils.payload import (
|
|||
apply_system_prompt_to_body,
|
||||
)
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
|
||||
|
||||
from open_webui.config import (
|
||||
UPLOAD_DIR,
|
||||
)
|
||||
|
|
@ -137,7 +136,7 @@ async def send_post_request(
|
|||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
if metadata and metadata.get("chat_id"):
|
||||
headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
|
||||
|
||||
r = await session.post(
|
||||
url,
|
||||
|
|
@ -430,8 +429,12 @@ async def get_filtered_models(models, user, db=None):
|
|||
for model in models.get("models", []):
|
||||
model_info = Models.get_model_by_id(model["model"], db=db)
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or has_access(
|
||||
user.id, type="read", access_control=model_info.access_control, db=db
|
||||
if user.id == model_info.user_id or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
):
|
||||
filtered_models.append(model)
|
||||
return filtered_models
|
||||
|
|
@ -442,6 +445,9 @@ async def get_filtered_models(models, user, db=None):
|
|||
async def get_ollama_tags(
|
||||
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
models = []
|
||||
|
||||
if url_idx is None:
|
||||
|
|
@ -706,6 +712,9 @@ async def pull_model(
|
|||
url_idx: int = 0,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
|
|
@ -737,6 +746,9 @@ async def push_model(
|
|||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
|
@ -776,6 +788,9 @@ async def create_model(
|
|||
url_idx: int = 0,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
log.debug(f"form_data: {form_data}")
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
|
||||
|
|
@ -800,6 +815,9 @@ async def copy_model(
|
|||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
|
@ -860,6 +878,9 @@ async def delete_model(
|
|||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
|
|
@ -922,6 +943,9 @@ async def delete_model(
|
|||
async def show_model_info(
|
||||
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
form_data = form_data.model_dump(exclude_none=True)
|
||||
form_data["model"] = form_data.get("model", form_data.get("name"))
|
||||
|
||||
|
|
@ -994,6 +1018,9 @@ async def embed(
|
|||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
log.info(f"generate_ollama_batch_embeddings {form_data}")
|
||||
|
||||
if url_idx is None:
|
||||
|
|
@ -1076,6 +1103,9 @@ async def embeddings(
|
|||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
log.info(f"generate_ollama_embeddings {form_data}")
|
||||
|
||||
if url_idx is None:
|
||||
|
|
@ -1166,6 +1196,9 @@ async def generate_completion(
|
|||
url_idx: Optional[int] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
if url_idx is None:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OLLAMA_MODELS
|
||||
|
|
@ -1258,8 +1291,11 @@ async def generate_chat_completion(
|
|||
bypass_filter: Optional[bool] = False,
|
||||
bypass_system_prompt: bool = False,
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OLLAMA_API:
|
||||
raise HTTPException(status_code=503, detail="Ollama API is disabled")
|
||||
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (get_model_by_id, has_access) manage their own short-lived sessions.
|
||||
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
|
||||
# which would exhaust the connection pool under concurrent load.
|
||||
if BYPASS_MODEL_ACCESS_CONTROL:
|
||||
|
|
@ -1306,10 +1342,11 @@ async def generate_chat_completion(
|
|||
if not bypass_filter and user.role == "user":
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -1383,7 +1420,7 @@ async def generate_openai_completion(
|
|||
user=Depends(get_verified_user),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (get_model_by_id, has_access) manage their own short-lived sessions.
|
||||
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
|
||||
# which would exhaust the connection pool under concurrent load.
|
||||
metadata = form_data.pop("metadata", None)
|
||||
|
|
@ -1418,10 +1455,11 @@ async def generate_openai_completion(
|
|||
if user.role == "user":
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -1468,7 +1506,7 @@ async def generate_openai_chat_completion(
|
|||
user=Depends(get_verified_user),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (get_model_by_id, has_access) manage their own short-lived sessions.
|
||||
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
|
||||
# which would exhaust the connection pool under concurrent load.
|
||||
metadata = form_data.pop("metadata", None)
|
||||
|
|
@ -1507,10 +1545,11 @@ async def generate_openai_chat_completion(
|
|||
if user.role == "user":
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -1608,10 +1647,11 @@ async def get_openai_models(
|
|||
for model in models:
|
||||
model_info = Models.get_model_by_id(model["id"], db=db)
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
if user.id == model_info.user_id or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from sqlalchemy.orm import Session
|
|||
from open_webui.internal.db import get_session
|
||||
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
)
|
||||
|
|
@ -33,6 +34,7 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
BYPASS_MODEL_ACCESS_CONTROL,
|
||||
)
|
||||
from open_webui.models.users import UserModel
|
||||
|
|
@ -50,7 +52,6 @@ from open_webui.utils.misc import (
|
|||
)
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
|
||||
|
|
@ -142,7 +143,7 @@ async def get_headers_and_cookies(
|
|||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
if metadata and metadata.get("chat_id"):
|
||||
headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
|
||||
|
||||
token = None
|
||||
auth_type = config.get("auth_type")
|
||||
|
|
@ -462,8 +463,12 @@ async def get_filtered_models(models, user, db=None):
|
|||
for model in models.get("data", []):
|
||||
model_info = Models.get_model_by_id(model["id"], db=db)
|
||||
if model_info:
|
||||
if user.id == model_info.user_id or has_access(
|
||||
user.id, type="read", access_control=model_info.access_control, db=db
|
||||
if user.id == model_info.user_id or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
):
|
||||
filtered_models.append(model)
|
||||
return filtered_models
|
||||
|
|
@ -544,6 +549,9 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
|||
async def get_models(
|
||||
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
|
||||
):
|
||||
if not request.app.state.config.ENABLE_OPENAI_API:
|
||||
raise HTTPException(status_code=503, detail="OpenAI API is disabled")
|
||||
|
||||
models = {
|
||||
"data": [],
|
||||
}
|
||||
|
|
@ -854,6 +862,33 @@ def convert_to_responses_payload(payload: dict) -> dict:
|
|||
if "max_tokens" in responses_payload:
|
||||
responses_payload["max_output_tokens"] = responses_payload.pop("max_tokens")
|
||||
|
||||
# Remove Chat Completions-only parameters not supported by the Responses API
|
||||
for unsupported_key in ("stream_options", "logit_bias", "frequency_penalty", "presence_penalty", "stop"):
|
||||
responses_payload.pop(unsupported_key, None)
|
||||
|
||||
# Convert Chat Completions tools format to Responses API format
|
||||
# Chat Completions: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
|
||||
# Responses API: {"type": "function", "name": ..., "description": ..., "parameters": ...}
|
||||
if "tools" in responses_payload and isinstance(responses_payload["tools"], list):
|
||||
converted_tools = []
|
||||
for tool in responses_payload["tools"]:
|
||||
if isinstance(tool, dict) and "function" in tool:
|
||||
func = tool["function"]
|
||||
converted_tool = {"type": tool.get("type", "function")}
|
||||
if isinstance(func, dict):
|
||||
converted_tool["name"] = func.get("name", "")
|
||||
if "description" in func:
|
||||
converted_tool["description"] = func["description"]
|
||||
if "parameters" in func:
|
||||
converted_tool["parameters"] = func["parameters"]
|
||||
if "strict" in func:
|
||||
converted_tool["strict"] = func["strict"]
|
||||
converted_tools.append(converted_tool)
|
||||
else:
|
||||
# Already in correct format or unknown format, pass through
|
||||
converted_tools.append(tool)
|
||||
responses_payload["tools"] = converted_tools
|
||||
|
||||
return responses_payload
|
||||
|
||||
|
||||
|
|
@ -876,7 +911,7 @@ async def generate_chat_completion(
|
|||
bypass_system_prompt: bool = False,
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (get_model_by_id, has_access) manage their own short-lived sessions.
|
||||
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
|
||||
# which would exhaust the connection pool under concurrent load.
|
||||
if BYPASS_MODEL_ACCESS_CONTROL:
|
||||
|
|
@ -914,10 +949,11 @@ async def generate_chat_completion(
|
|||
if not bypass_filter and user.role == "user":
|
||||
if not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from open_webui.models.prompts import (
|
|||
PromptModel,
|
||||
Prompts,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.prompt_history import (
|
||||
PromptHistories,
|
||||
|
|
@ -17,7 +18,7 @@ from open_webui.models.prompt_history import (
|
|||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -115,7 +116,13 @@ async def get_prompt_list(
|
|||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == prompt.user_id
|
||||
or has_access(user.id, "write", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
for prompt in result.items
|
||||
|
|
@ -186,14 +193,26 @@ async def get_prompt_by_command(
|
|||
if (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or has_access(user.id, "read", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return PromptAccessResponse(
|
||||
**prompt.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == prompt.user_id
|
||||
or has_access(user.id, "write", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -218,14 +237,26 @@ async def get_prompt_by_id(
|
|||
if (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or has_access(user.id, "read", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return PromptAccessResponse(
|
||||
**prompt.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == prompt.user_id
|
||||
or has_access(user.id, "write", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -258,7 +289,13 @@ async def update_prompt_by_id(
|
|||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not has_access(user.id, "write", prompt.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -311,7 +348,13 @@ async def update_prompt_metadata(
|
|||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not has_access(user.id, "write", prompt.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -356,7 +399,13 @@ async def set_prompt_version(
|
|||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not has_access(user.id, "write", prompt.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -395,7 +444,13 @@ async def delete_prompt_by_id(
|
|||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not has_access(user.id, "write", prompt.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -434,7 +489,13 @@ async def get_prompt_history(
|
|||
if not (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or has_access(user.id, "read", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -469,7 +530,13 @@ async def get_prompt_history_entry(
|
|||
if not (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or has_access(user.id, "read", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -508,7 +575,13 @@ async def delete_prompt_history_entry(
|
|||
if not (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or has_access(user.id, "write", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -553,7 +626,13 @@ async def get_prompt_diff(
|
|||
if not (
|
||||
user.role == "admin"
|
||||
or prompt.user_id == user.id
|
||||
or has_access(user.id, "read", prompt.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -1056,13 +1056,25 @@ async def update_rag_config(
|
|||
)
|
||||
|
||||
# File upload settings
|
||||
request.app.state.config.FILE_MAX_SIZE = form_data.FILE_MAX_SIZE
|
||||
request.app.state.config.FILE_MAX_COUNT = form_data.FILE_MAX_COUNT
|
||||
request.app.state.config.FILE_MAX_SIZE = (
|
||||
form_data.FILE_MAX_SIZE
|
||||
if form_data.FILE_MAX_SIZE is not None
|
||||
else request.app.state.config.FILE_MAX_SIZE
|
||||
)
|
||||
request.app.state.config.FILE_MAX_COUNT = (
|
||||
form_data.FILE_MAX_COUNT
|
||||
if form_data.FILE_MAX_COUNT is not None
|
||||
else request.app.state.config.FILE_MAX_COUNT
|
||||
)
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
|
||||
form_data.FILE_IMAGE_COMPRESSION_WIDTH
|
||||
if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None
|
||||
else request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH
|
||||
)
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
|
||||
form_data.FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None
|
||||
else request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
)
|
||||
request.app.state.config.ALLOWED_FILE_EXTENSIONS = (
|
||||
form_data.ALLOWED_FILE_EXTENSIONS
|
||||
|
|
|
|||
|
|
@ -49,6 +49,21 @@ router = APIRouter()
|
|||
##################################
|
||||
|
||||
|
||||
class ActiveChatsForm(BaseModel):
|
||||
chat_ids: list[str]
|
||||
|
||||
|
||||
@router.post("/active/chats")
|
||||
async def check_active_chats(
|
||||
request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)
|
||||
):
|
||||
"""Check which chat IDs have active tasks."""
|
||||
from open_webui.tasks import get_active_chat_ids
|
||||
|
||||
active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids)
|
||||
return {"active_chat_ids": active}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_task_config(request: Request, user=Depends(get_verified_user)):
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from open_webui.models.tools import (
|
|||
ToolAccessResponse,
|
||||
Tools,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.plugin import (
|
||||
load_tool_module_by_id,
|
||||
replace_imports,
|
||||
|
|
@ -156,7 +157,24 @@ async def get_tools(
|
|||
tool
|
||||
for tool in tools
|
||||
if tool.user_id == user.id
|
||||
or has_access(user.id, "read", tool.access_control, user_group_ids, db=db)
|
||||
or (
|
||||
has_access(
|
||||
user.id,
|
||||
"read",
|
||||
getattr(tool, "access_control", None),
|
||||
user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
if str(tool.id).startswith("server:")
|
||||
else AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tool.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
]
|
||||
return tools
|
||||
|
||||
|
|
@ -181,7 +199,13 @@ async def get_tool_list(
|
|||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == tool.user_id
|
||||
or has_access(user.id, "write", tool.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tool.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
for tool in tools
|
||||
|
|
@ -382,14 +406,26 @@ async def get_tools_by_id(
|
|||
if (
|
||||
user.role == "admin"
|
||||
or tools.user_id == user.id
|
||||
or has_access(user.id, "read", tools.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
return ToolAccessResponse(
|
||||
**tools.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == tools.user_id
|
||||
or has_access(user.id, "write", tools.access_control, db=db)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
|
|
@ -427,7 +463,13 @@ async def update_tools_by_id(
|
|||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not has_access(user.id, "write", tools.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -489,7 +531,13 @@ async def delete_tools_by_id(
|
|||
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not has_access(user.id, "write", tools.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
@ -588,7 +636,13 @@ async def update_tools_valves_by_id(
|
|||
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not has_access(user.id, "write", tools.access_control, db=db)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tools.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from open_webui.models.users import (
|
|||
UserModel,
|
||||
UserGroupIdsModel,
|
||||
UserGroupIdsListResponse,
|
||||
UserInfoListResponse,
|
||||
UserInfoResponse,
|
||||
UserInfoListResponse,
|
||||
UserRoleUpdateForm,
|
||||
UserStatus,
|
||||
|
|
@ -446,7 +446,7 @@ class UserActiveResponse(UserStatus):
|
|||
|
||||
@router.get("/{user_id}", response_model=UserActiveResponse)
|
||||
async def get_user_by_id(
|
||||
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
|
||||
):
|
||||
# Check if user_id is a shared chat
|
||||
# If it is, get the user_id from the chat
|
||||
|
|
@ -478,6 +478,27 @@ async def get_user_by_id(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}/info", response_model=UserInfoResponse)
|
||||
async def get_user_info_by_id(
|
||||
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
user = Users.get_user_by_id(user_id, db=db)
|
||||
if user:
|
||||
groups = Groups.get_groups_by_member_id(user_id, db=db)
|
||||
return UserInfoResponse(
|
||||
**{
|
||||
**user.model_dump(),
|
||||
"groups": [{"id": group.id, "name": group.name} for group in groups],
|
||||
"is_active": Users.is_user_active(user_id, db=db),
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}/oauth/sessions")
|
||||
async def get_user_oauth_sessions_by_id(
|
||||
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ from open_webui.utils.auth import decode_token
|
|||
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
|
||||
from open_webui.tasks import create_task, stop_item_tasks
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
from open_webui.utils.access_control import has_access, get_users_with_access
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
|
||||
from open_webui.env import (
|
||||
|
|
@ -405,7 +405,12 @@ async def join_note(sid, data):
|
|||
if (
|
||||
user.role != "admin"
|
||||
and user.id != note.user_id
|
||||
and not has_access(user.id, type="read", access_control=note.access_control)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
log.error(f"User {user.id} does not have access to note {data['note_id']}")
|
||||
return
|
||||
|
|
@ -467,8 +472,11 @@ async def ydoc_document_join(sid, data):
|
|||
if (
|
||||
user.get("role") != "admin"
|
||||
and user.get("id") != note.user_id
|
||||
and not has_access(
|
||||
user.get("id"), type="read", access_control=note.access_control
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.get("id"),
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
log.error(
|
||||
|
|
@ -537,8 +545,11 @@ async def document_save_handler(document_id, data, user):
|
|||
if (
|
||||
user.get("role") != "admin"
|
||||
and user.get("id") != note.user_id
|
||||
and not has_access(
|
||||
user.get("id"), type="read", access_control=note.access_control
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.get("id"),
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
log.error(f"User {user.get('id')} does not have access to note {note_id}")
|
||||
|
|
|
|||
|
|
@ -183,3 +183,18 @@ async def stop_item_tasks(redis: Redis, item_id: str):
|
|||
return result # Return the first failure
|
||||
|
||||
return {"status": True, "message": f"All tasks for item {item_id} stopped."}
|
||||
|
||||
|
||||
async def has_active_tasks(redis, chat_id: str) -> bool:
|
||||
"""Check if a chat has any active tasks."""
|
||||
task_ids = await list_task_ids_by_item_id(redis, chat_id)
|
||||
return len(task_ids) > 0
|
||||
|
||||
|
||||
async def get_active_chat_ids(redis, chat_ids: List[str]) -> List[str]:
|
||||
"""Filter a list of chat_ids to only those with active tasks."""
|
||||
active = []
|
||||
for chat_id in chat_ids:
|
||||
if await has_active_tasks(redis, chat_id):
|
||||
active.append(chat_id)
|
||||
return active
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ from open_webui.models.chats import Chats
|
|||
from open_webui.models.channels import Channels, ChannelMember, Channel
|
||||
from open_webui.models.messages import Messages, Message
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.sanitize import strip_markdown_code_fences
|
||||
from open_webui.utils.sanitize import sanitize_code
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -371,8 +371,8 @@ async def execute_code(
|
|||
return json.dumps({"error": "Request context not available"})
|
||||
|
||||
try:
|
||||
# Strip markdown fences if model included them
|
||||
code = strip_markdown_code_fences(code)
|
||||
# Sanitize code (strips ANSI codes and markdown fences)
|
||||
code = sanitize_code(code)
|
||||
|
||||
# Import blocked modules from config (same as middleware)
|
||||
from open_webui.config import CODE_INTERPRETER_BLOCKED_MODULES
|
||||
|
|
@ -743,10 +743,14 @@ async def view_note(
|
|||
user_id = __user__.get("id")
|
||||
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
if note.user_id != user_id and not has_access(
|
||||
user_id, "read", note.access_control, user_group_ids
|
||||
if note.user_id != user_id and not AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
user_group_ids=set(user_group_ids),
|
||||
):
|
||||
return json.dumps({"error": "Access denied"})
|
||||
|
||||
|
|
@ -797,7 +801,7 @@ async def write_note(
|
|||
form = NoteForm(
|
||||
title=title,
|
||||
data={"content": {"md": content}},
|
||||
access_control={}, # Private by default - only owner can access
|
||||
access_grants=[], # Private by default - only owner can access
|
||||
)
|
||||
|
||||
new_note = Notes.insert_new_note(user_id, form)
|
||||
|
|
@ -852,10 +856,14 @@ async def replace_note_content(
|
|||
user_id = __user__.get("id")
|
||||
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
|
||||
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
if note.user_id != user_id and not has_access(
|
||||
user_id, "write", note.access_control, user_group_ids
|
||||
if note.user_id != user_id and not AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="write",
|
||||
user_group_ids=set(user_group_ids),
|
||||
):
|
||||
return json.dumps({"error": "Write access denied"})
|
||||
|
||||
|
|
@ -1532,7 +1540,7 @@ async def view_knowledge_file(
|
|||
try:
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_id = __user__.get("id")
|
||||
user_role = __user__.get("role", "user")
|
||||
|
|
@ -1551,8 +1559,12 @@ async def view_knowledge_file(
|
|||
if (
|
||||
user_role == "admin"
|
||||
or knowledge_base.user_id == user_id
|
||||
or has_access(
|
||||
user_id, "read", knowledge_base.access_control, user_group_ids
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission="read",
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
):
|
||||
has_knowledge_access = True
|
||||
|
|
@ -1631,7 +1643,7 @@ async def query_knowledge_files(
|
|||
from open_webui.models.files import Files
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.retrieval.utils import query_collection
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_id = __user__.get("id")
|
||||
user_role = __user__.get("role", "user")
|
||||
|
|
@ -1656,8 +1668,12 @@ async def query_knowledge_files(
|
|||
if knowledge and (
|
||||
user_role == "admin"
|
||||
or knowledge.user_id == user_id
|
||||
or has_access(
|
||||
user_id, "read", knowledge.access_control, user_group_ids
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="read",
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
):
|
||||
collection_names.append(item_id)
|
||||
|
|
@ -1674,7 +1690,12 @@ async def query_knowledge_files(
|
|||
if note and (
|
||||
user_role == "admin"
|
||||
or note.user_id == user_id
|
||||
or has_access(user_id, "read", note.access_control)
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="note",
|
||||
resource_id=note.id,
|
||||
permission="read",
|
||||
)
|
||||
):
|
||||
content = note.data.get("content", {}).get("md", "")
|
||||
note_results.append(
|
||||
|
|
@ -1693,8 +1714,12 @@ async def query_knowledge_files(
|
|||
if knowledge and (
|
||||
user_role == "admin"
|
||||
or knowledge.user_id == user_id
|
||||
or has_access(
|
||||
user_id, "read", knowledge.access_control, user_group_ids
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge.id,
|
||||
permission="read",
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
):
|
||||
collection_names.append(knowledge_id)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
from urllib.parse import quote
|
||||
|
||||
from open_webui.env import (
|
||||
FORWARD_USER_INFO_HEADER_USER_NAME,
|
||||
FORWARD_USER_INFO_HEADER_USER_ID,
|
||||
FORWARD_USER_INFO_HEADER_USER_EMAIL,
|
||||
FORWARD_USER_INFO_HEADER_USER_ROLE,
|
||||
)
|
||||
|
||||
|
||||
def include_user_info_headers(headers, user):
|
||||
return {
|
||||
**headers,
|
||||
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
|
||||
"X-OpenWebUI-User-Id": user.id,
|
||||
"X-OpenWebUI-User-Email": user.email,
|
||||
"X-OpenWebUI-User-Role": user.role,
|
||||
FORWARD_USER_INFO_HEADER_USER_NAME: quote(user.name, safe=" "),
|
||||
FORWARD_USER_INFO_HEADER_USER_ID: user.id,
|
||||
FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email,
|
||||
FORWARD_USER_INFO_HEADER_USER_ROLE: user.role,
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -128,22 +128,40 @@ def get_content_from_message(message: dict) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def convert_output_to_messages(output: list) -> list[dict]:
|
||||
def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
||||
"""
|
||||
Convert OR-aligned output items to OpenAI-format messages for LLM consumption.
|
||||
|
||||
This is the inverse of convert_content_blocks_to_output() in middleware.py.
|
||||
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
|
||||
|
||||
This reconstructs the full conversation from the stored Responses API-native
|
||||
output items, including assistant messages with tool_calls arrays and tool
|
||||
role messages.
|
||||
|
||||
Args:
|
||||
output: List of OR-aligned output items (Responses API format).
|
||||
raw: If True, include reasoning blocks (with original tags) and code
|
||||
interpreter blocks for LLM re-processing follow-ups.
|
||||
"""
|
||||
if not output or not isinstance(output, list):
|
||||
return []
|
||||
|
||||
|
||||
messages = []
|
||||
pending_tool_calls = []
|
||||
pending_content = []
|
||||
|
||||
|
||||
def flush_pending():
|
||||
nonlocal pending_content, pending_tool_calls
|
||||
if pending_content or pending_tool_calls:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "\n".join(pending_content) if pending_content else "",
|
||||
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
|
||||
})
|
||||
pending_content = []
|
||||
pending_tool_calls = []
|
||||
|
||||
for item in output:
|
||||
item_type = item.get("type", "")
|
||||
|
||||
|
||||
if item_type == "message":
|
||||
# Extract text from output_text content parts
|
||||
content_parts = item.get("content", [])
|
||||
|
|
@ -153,58 +171,86 @@ def convert_output_to_messages(output: list) -> list[dict]:
|
|||
text += part.get("text", "")
|
||||
if text:
|
||||
pending_content.append(text)
|
||||
|
||||
|
||||
elif item_type == "function_call":
|
||||
# Collect tool calls to batch into assistant message
|
||||
arguments = item.get("arguments", "{}")
|
||||
# Ensure arguments is always a JSON string
|
||||
if not isinstance(arguments, str):
|
||||
arguments = json.dumps(arguments)
|
||||
pending_tool_calls.append({
|
||||
"id": item.get("call_id", ""),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": item.get("name", ""),
|
||||
"arguments": item.get("arguments", "{}"),
|
||||
"arguments": arguments,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
elif item_type == "function_call_output":
|
||||
# Flush any pending content/tool_calls before adding tool result
|
||||
if pending_content or pending_tool_calls:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "\n".join(pending_content) if pending_content else "",
|
||||
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
|
||||
})
|
||||
pending_content = []
|
||||
pending_tool_calls = []
|
||||
|
||||
flush_pending()
|
||||
|
||||
# Extract text from output content parts
|
||||
output_parts = item.get("output", [])
|
||||
content = ""
|
||||
for part in output_parts:
|
||||
if part.get("type") == "input_text":
|
||||
content += part.get("text", "")
|
||||
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": item.get("call_id", ""),
|
||||
"content": content,
|
||||
})
|
||||
|
||||
|
||||
elif item_type == "reasoning":
|
||||
# Skip reasoning blocks for LLM messages
|
||||
pass
|
||||
|
||||
if raw:
|
||||
# Include reasoning with original tags for LLM re-processing
|
||||
reasoning_text = ""
|
||||
source_list = item.get("summary", []) or item.get("content", [])
|
||||
for part in source_list:
|
||||
if part.get("type") == "output_text":
|
||||
reasoning_text += part.get("text", "")
|
||||
elif "text" in part:
|
||||
reasoning_text += part.get("text", "")
|
||||
|
||||
if reasoning_text:
|
||||
start_tag = item.get("start_tag", "<think>")
|
||||
end_tag = item.get("end_tag", "</think>")
|
||||
pending_content.append(
|
||||
f"{start_tag}{reasoning_text}{end_tag}"
|
||||
)
|
||||
# else: skip reasoning blocks for normal LLM messages
|
||||
|
||||
elif item_type == "open_webui:code_interpreter":
|
||||
if raw:
|
||||
# Include code interpreter content for LLM re-processing
|
||||
code = item.get("code", "")
|
||||
code_output = item.get("output", "")
|
||||
|
||||
if code:
|
||||
lang = item.get("lang", "python")
|
||||
pending_content.append(f"```{lang}\n{code}\n```")
|
||||
|
||||
if code_output:
|
||||
if isinstance(code_output, dict):
|
||||
stdout = code_output.get("stdout", "")
|
||||
result = code_output.get("result", "")
|
||||
output_text = stdout or result
|
||||
else:
|
||||
output_text = str(code_output)
|
||||
if output_text:
|
||||
pending_content.append(f"Output:\n{output_text}")
|
||||
# else: skip extension types
|
||||
|
||||
elif item_type.startswith("open_webui:"):
|
||||
# Skip extension types
|
||||
# Skip other extension types
|
||||
pass
|
||||
|
||||
|
||||
# Flush remaining content/tool_calls
|
||||
if pending_content or pending_tool_calls:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "\n".join(pending_content) if pending_content else "",
|
||||
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
|
||||
})
|
||||
|
||||
flush_pending()
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from open_webui.functions import get_function_models
|
|||
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
|
||||
|
|
@ -354,8 +355,12 @@ def check_model_access(user, model, db=None):
|
|||
raise Exception("Model not found")
|
||||
elif not (
|
||||
user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id, type="read", access_control=model_info.access_control, db=db
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise Exception("Model not found")
|
||||
|
|
@ -395,11 +400,13 @@ def get_filtered_models(models, user, db=None):
|
|||
if (
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="model",
|
||||
resource_id=model_info.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,25 @@
|
|||
import re
|
||||
|
||||
# ANSI escape code pattern - matches all common ANSI sequences
|
||||
# This includes color codes, cursor movement, and other terminal control sequences
|
||||
ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x1b\([AB]|\x1b[PX^_].*?\x1b\\|\x1b\].*?(?:\x07|\x1b\\)')
|
||||
|
||||
|
||||
def strip_ansi_codes(text: str) -> str:
|
||||
"""
|
||||
Strip ANSI escape codes from text.
|
||||
|
||||
ANSI escape codes can be introduced by LLMs that include terminal
|
||||
color codes in their output. These codes cause syntax errors when
|
||||
the code is sent to Jupyter for execution.
|
||||
|
||||
Common ANSI codes include:
|
||||
- Color codes: \x1b[31m (red), \x1b[32m (green), etc.
|
||||
- Reset codes: \x1b[0m, \x1b[39m
|
||||
- Cursor movement: \x1b[1A, \x1b[2J, etc.
|
||||
"""
|
||||
return ANSI_ESCAPE_PATTERN.sub('', text)
|
||||
|
||||
|
||||
def strip_markdown_code_fences(code: str) -> str:
|
||||
"""
|
||||
|
|
@ -19,3 +39,20 @@ def strip_markdown_code_fences(code: str) -> str:
|
|||
# Remove closing fence
|
||||
code = re.sub(r"\n?```\s*$", "", code)
|
||||
return code.strip()
|
||||
|
||||
|
||||
def sanitize_code(code: str) -> str:
|
||||
"""
|
||||
Sanitize code for execution by applying all necessary cleanup steps.
|
||||
|
||||
This is the recommended function to use before sending code to
|
||||
interpreters like Jupyter or Pyodide.
|
||||
|
||||
Steps applied:
|
||||
1. Strip ANSI escape codes (from LLM output)
|
||||
2. Strip markdown code fences (if model included them)
|
||||
"""
|
||||
code = strip_ansi_codes(code)
|
||||
code = strip_markdown_code_fences(code)
|
||||
return code
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from open_webui.utils.misc import is_string_allowed
|
|||
from open_webui.models.tools import Tools
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.plugin import load_tool_module_by_id
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
|
@ -45,7 +46,10 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
|
||||
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
)
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.tools.builtin import (
|
||||
search_web,
|
||||
fetch_url,
|
||||
|
|
@ -165,7 +169,13 @@ async def get_tools(
|
|||
if (
|
||||
not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
and tool.user_id != user.id
|
||||
and not has_access(user.id, "read", tool.access_control, user_group_ids)
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tool.id,
|
||||
permission="read",
|
||||
user_group_ids=user_group_ids,
|
||||
)
|
||||
):
|
||||
log.warning(f"Access denied to tool {tool_id} for user {user.id}")
|
||||
continue
|
||||
|
|
@ -335,6 +345,13 @@ async def get_tools(
|
|||
for key, value in connection_headers.items():
|
||||
headers[key] = value
|
||||
|
||||
# Add user info headers if enabled
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
metadata = extra_params.get("__metadata__", {})
|
||||
if metadata and metadata.get("chat_id"):
|
||||
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
|
||||
|
||||
def make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
):
|
||||
|
|
@ -697,9 +714,10 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
|||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
|
||||
# Extract path and query parameters
|
||||
for param in operation.get("parameters", []):
|
||||
param_name = param["name"]
|
||||
param_name = param.get("name")
|
||||
if not param_name:
|
||||
continue
|
||||
param_schema = param.get("schema", {})
|
||||
description = param_schema.get("description", "")
|
||||
if not description:
|
||||
|
|
@ -971,8 +989,10 @@ async def execute_tool_server(
|
|||
body_params = {}
|
||||
|
||||
for param in operation.get("parameters", []):
|
||||
param_name = param["name"]
|
||||
param_in = param["in"]
|
||||
param_name = param.get("name")
|
||||
if not param_name:
|
||||
continue
|
||||
param_in = param.get("in")
|
||||
if param_name in params:
|
||||
if param_in == "path":
|
||||
path_params[param_name] = params[param_name]
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
# Minimal requirements for backend to run
|
||||
# WIP: use this as a reference to build a minimal docker image
|
||||
|
||||
fastapi==0.128.0
|
||||
fastapi==0.128.5
|
||||
uvicorn[standard]==0.40.0
|
||||
pydantic==2.12.5
|
||||
python-multipart==0.0.22
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.16.0
|
||||
python-socketio==5.16.1
|
||||
python-jose==3.5.0
|
||||
cryptography
|
||||
bcrypt==5.0.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.6
|
||||
PyJWT[crypto]==2.11.0
|
||||
authlib==1.6.7
|
||||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
|
|
@ -29,19 +29,19 @@ alembic==1.18.3
|
|||
peewee==3.19.0
|
||||
peewee-migrate==1.14.3
|
||||
|
||||
pycrdt==0.12.45
|
||||
pycrdt==0.12.46
|
||||
redis
|
||||
|
||||
APScheduler==3.11.2
|
||||
RestrictedPython==8.1
|
||||
|
||||
loguru==0.7.3
|
||||
asgiref==3.11.0
|
||||
asgiref==3.11.1
|
||||
|
||||
mcp==1.26.0
|
||||
openai
|
||||
|
||||
langchain==1.2.7
|
||||
langchain==1.2.9
|
||||
langchain-community==0.4.1
|
||||
langchain-classic==1.0.1
|
||||
langchain-text-splitters==1.1.0
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
fastapi==0.128.0
|
||||
fastapi==0.128.5
|
||||
uvicorn[standard]==0.40.0
|
||||
pydantic==2.12.5
|
||||
python-multipart==0.0.22
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.16.0
|
||||
python-socketio==5.16.1
|
||||
python-jose==3.5.0
|
||||
cryptography
|
||||
bcrypt==5.0.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.6
|
||||
PyJWT[crypto]==2.11.0
|
||||
authlib==1.6.7
|
||||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
|
|
@ -27,14 +27,15 @@ alembic==1.18.3
|
|||
peewee==3.19.0
|
||||
peewee-migrate==1.14.3
|
||||
|
||||
pycrdt==0.12.45
|
||||
pycrdt==0.12.46
|
||||
redis
|
||||
|
||||
APScheduler==3.11.2
|
||||
RestrictedPython==8.1
|
||||
pytz==2025.2
|
||||
|
||||
loguru==0.7.3
|
||||
asgiref==3.11.0
|
||||
asgiref==3.11.1
|
||||
|
||||
# AI libraries
|
||||
tiktoken
|
||||
|
|
@ -42,9 +43,9 @@ mcp==1.26.0
|
|||
|
||||
openai
|
||||
anthropic
|
||||
google-genai==1.60.0
|
||||
google-genai==1.62.0
|
||||
|
||||
langchain==1.2.7
|
||||
langchain==1.2.9
|
||||
langchain-community==0.4.1
|
||||
langchain-classic==1.0.1
|
||||
langchain-text-splitters==1.1.0
|
||||
|
|
@ -54,7 +55,7 @@ chromadb==1.4.1
|
|||
weaviate-client==4.19.2
|
||||
opensearch-py==3.1.0
|
||||
|
||||
transformers==4.57.6
|
||||
transformers==5.1.0
|
||||
sentence-transformers==5.2.2
|
||||
accelerate
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
|
|
@ -62,17 +63,17 @@ einops==0.8.2
|
|||
|
||||
ftfy==6.3.1
|
||||
chardet==5.2.0
|
||||
pypdf==6.6.2
|
||||
pypdf==6.7.0
|
||||
fpdf2==2.8.5
|
||||
pymdown-extensions==10.20.1
|
||||
docx2txt==0.9
|
||||
python-pptx==1.0.2
|
||||
unstructured==0.18.31
|
||||
msoffcrypto-tool==5.4.2
|
||||
msoffcrypto-tool==6.0.0
|
||||
nltk==3.9.2
|
||||
Markdown==3.10.1
|
||||
pypandoc==1.16.2
|
||||
pandas==2.3.3
|
||||
pandas==3.0.0
|
||||
openpyxl==3.1.5
|
||||
pyxlsb==1.0.10
|
||||
xlrd==2.0.2
|
||||
|
|
@ -82,11 +83,11 @@ sentencepiece
|
|||
soundfile==0.13.1
|
||||
|
||||
pillow==12.1.0
|
||||
opencv-python-headless==4.13.0.90
|
||||
opencv-python-headless==4.13.0.92
|
||||
rapidocr-onnxruntime==1.4.4
|
||||
rank-bm25==0.2.2
|
||||
|
||||
onnxruntime==1.23.2
|
||||
onnxruntime==1.24.1
|
||||
faster-whisper==1.2.1
|
||||
|
||||
black==26.1.0
|
||||
|
|
@ -107,7 +108,7 @@ google-auth-httplib2
|
|||
google-auth-oauthlib
|
||||
|
||||
googleapis-common-protos==1.72.0
|
||||
google-cloud-storage==3.8.0
|
||||
google-cloud-storage==3.9.0
|
||||
|
||||
## Databases
|
||||
pymongo
|
||||
|
|
@ -115,12 +116,12 @@ psycopg2-binary==2.9.11
|
|||
pgvector==0.4.2
|
||||
|
||||
PyMySQL==1.1.2
|
||||
boto3==1.42.38
|
||||
boto3==1.42.44
|
||||
|
||||
pymilvus==2.6.8
|
||||
qdrant-client==1.16.2
|
||||
playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||
elasticsearch==9.2.1
|
||||
elasticsearch==9.3.0
|
||||
pinecone==6.0.2
|
||||
oracledb==3.4.2
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@ authors = [
|
|||
]
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"fastapi==0.128.0",
|
||||
"fastapi==0.128.5",
|
||||
"uvicorn[standard]==0.40.0",
|
||||
"pydantic==2.12.5",
|
||||
"python-multipart==0.0.22",
|
||||
"itsdangerous==2.2.0",
|
||||
|
||||
"python-socketio==5.16.0",
|
||||
"python-socketio==5.16.1",
|
||||
"python-jose==3.5.0",
|
||||
"cryptography",
|
||||
"bcrypt==5.0.0",
|
||||
"argon2-cffi==25.1.0",
|
||||
"PyJWT[crypto]==2.10.1",
|
||||
"authlib==1.6.6",
|
||||
"PyJWT[crypto]==2.11.0",
|
||||
"authlib==1.6.7",
|
||||
|
||||
"requests==2.32.5",
|
||||
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
|
||||
|
|
@ -35,23 +35,24 @@ dependencies = [
|
|||
"peewee==3.19.0",
|
||||
"peewee-migrate==1.14.3",
|
||||
|
||||
"pycrdt==0.12.45",
|
||||
"pycrdt==0.12.46",
|
||||
"redis",
|
||||
|
||||
"pytz==2025.2",
|
||||
"APScheduler==3.11.2",
|
||||
"RestrictedPython==8.1",
|
||||
|
||||
"loguru==0.7.3",
|
||||
"asgiref==3.11.0",
|
||||
"asgiref==3.11.1",
|
||||
|
||||
"tiktoken",
|
||||
"mcp==1.26.0",
|
||||
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-genai==1.60.0",
|
||||
"google-genai==1.62.0",
|
||||
|
||||
"langchain==1.2.7",
|
||||
"langchain==1.2.9",
|
||||
"langchain-community==0.4.1",
|
||||
"langchain-classic==1.0.1",
|
||||
"langchain-text-splitters==1.1.0",
|
||||
|
|
@ -60,9 +61,9 @@ dependencies = [
|
|||
"chromadb==1.4.1",
|
||||
"opensearch-py==3.1.0",
|
||||
"PyMySQL==1.1.2",
|
||||
"boto3==1.42.38",
|
||||
"boto3==1.42.44",
|
||||
|
||||
"transformers==4.57.6",
|
||||
"transformers==5.1.0",
|
||||
"sentence-transformers==5.2.2",
|
||||
"accelerate",
|
||||
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
|
|
@ -70,17 +71,17 @@ dependencies = [
|
|||
|
||||
"ftfy==6.3.1",
|
||||
"chardet==5.2.0",
|
||||
"pypdf==6.6.2",
|
||||
"pypdf==6.7.0",
|
||||
"fpdf2==2.8.5",
|
||||
"pymdown-extensions==10.20.1",
|
||||
"docx2txt==0.9",
|
||||
"python-pptx==1.0.2",
|
||||
"unstructured==0.18.31",
|
||||
"msoffcrypto-tool==5.4.2",
|
||||
"msoffcrypto-tool==6.0.0",
|
||||
"nltk==3.9.2",
|
||||
"Markdown==3.10.1",
|
||||
"pypandoc==1.16.2",
|
||||
"pandas==2.3.3",
|
||||
"pandas==3.0.0",
|
||||
"openpyxl==3.1.5",
|
||||
"pyxlsb==1.0.10",
|
||||
"xlrd==2.0.2",
|
||||
|
|
@ -91,11 +92,11 @@ dependencies = [
|
|||
"azure-ai-documentintelligence==1.0.2",
|
||||
|
||||
"pillow==12.1.0",
|
||||
"opencv-python-headless==4.13.0.90",
|
||||
"opencv-python-headless==4.13.0.92",
|
||||
"rapidocr-onnxruntime==1.4.4",
|
||||
"rank-bm25==0.2.2",
|
||||
|
||||
"onnxruntime==1.23.2",
|
||||
"onnxruntime==1.24.1",
|
||||
"faster-whisper==1.2.1",
|
||||
|
||||
"black==26.1.0",
|
||||
|
|
@ -110,7 +111,7 @@ dependencies = [
|
|||
"google-auth-oauthlib",
|
||||
|
||||
"googleapis-common-protos==1.72.0",
|
||||
"google-cloud-storage==3.8.0",
|
||||
"google-cloud-storage==3.9.0",
|
||||
|
||||
"azure-identity==1.25.1",
|
||||
"azure-storage-blob==12.28.0",
|
||||
|
|
@ -146,7 +147,7 @@ all = [
|
|||
"pytest~=8.3.2",
|
||||
"pytest-docker~=3.2.5",
|
||||
"playwright==1.58.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||
"elasticsearch==9.2.1",
|
||||
"elasticsearch==9.3.0",
|
||||
|
||||
"qdrant-client==1.16.2",
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import { WEBUI_API_BASE_URL } from '$lib/constants';
|
|||
type ChannelForm = {
|
||||
type?: string;
|
||||
name: string;
|
||||
is_private?: boolean;
|
||||
is_private?: boolean | null;
|
||||
data?: object;
|
||||
meta?: object;
|
||||
access_control?: object;
|
||||
access_grants?: object[];
|
||||
group_ids?: string[];
|
||||
user_ids?: string[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,38 @@ export const getGroupById = async (token: string, id: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getGroupInfoById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/info`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateGroupById = async (token: string, id: string, group: object) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -449,8 +449,9 @@ export const executeToolServer = async (
|
|||
|
||||
if (operation.parameters) {
|
||||
operation.parameters.forEach((param: any) => {
|
||||
const paramName = param.name;
|
||||
const paramIn = param.in;
|
||||
const paramName = param?.name;
|
||||
if (!paramName) return;
|
||||
const paramIn = param?.in;
|
||||
if (params.hasOwnProperty(paramName)) {
|
||||
if (paramIn === 'path') {
|
||||
pathParams[paramName] = params[paramName];
|
||||
|
|
@ -1674,7 +1675,7 @@ export interface ModelMeta {
|
|||
profile_image_url?: string;
|
||||
}
|
||||
|
||||
export interface ModelParams {}
|
||||
export interface ModelParams { }
|
||||
|
||||
export type GlobalModelConfig = ModelConfig[];
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export const createNewKnowledge = async (
|
|||
token: string,
|
||||
name: string,
|
||||
description: string,
|
||||
accessControl: null | object
|
||||
accessGrants: object[]
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ export const createNewKnowledge = async (
|
|||
body: JSON.stringify({
|
||||
name: name,
|
||||
description: description,
|
||||
access_control: accessControl
|
||||
access_grants: accessGrants
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
|
@ -248,7 +248,7 @@ type KnowledgeUpdateForm = {
|
|||
name?: string;
|
||||
description?: string;
|
||||
data?: object;
|
||||
access_control?: null | object;
|
||||
access_grants?: object[];
|
||||
};
|
||||
|
||||
export const updateKnowledgeById = async (token: string, id: string, form: KnowledgeUpdateForm) => {
|
||||
|
|
@ -265,7 +265,7 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl
|
|||
name: form?.name ? form.name : undefined,
|
||||
description: form?.description ? form.description : undefined,
|
||||
data: form?.data ? form.data : undefined,
|
||||
access_control: form.access_control
|
||||
access_grants: form.access_grants
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ type NoteItem = {
|
|||
title: string;
|
||||
data: object;
|
||||
meta?: null | object;
|
||||
access_control?: null | object;
|
||||
access_grants?: object[];
|
||||
};
|
||||
|
||||
export const createNewNote = async (token: string, note: NoteItem) => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ type PromptItem = {
|
|||
content: string;
|
||||
data?: object | null;
|
||||
meta?: object | null;
|
||||
access_control?: null | object;
|
||||
access_grants?: object[];
|
||||
version_id?: string | null; // Active version
|
||||
commit_message?: string | null; // For history tracking
|
||||
is_production?: boolean; // Whether to set new version as production
|
||||
|
|
@ -23,7 +23,7 @@ type PromptHistoryItem = {
|
|||
command: string;
|
||||
data: object;
|
||||
meta: object;
|
||||
access_control: object | null;
|
||||
access_grants: object[];
|
||||
};
|
||||
user_id: string;
|
||||
commit_message: string | null;
|
||||
|
|
@ -42,7 +42,7 @@ type PromptDiff = {
|
|||
to_snapshot: object;
|
||||
content_diff: string[];
|
||||
name_changed: boolean;
|
||||
access_control_changed: boolean;
|
||||
access_grants_changed: boolean;
|
||||
};
|
||||
|
||||
export const createNewPrompt = async (token: string, prompt: PromptItem) => {
|
||||
|
|
@ -611,4 +611,3 @@ export const getPromptDiff = async (
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
|
|
|
|||
14
src/lib/apis/tasks/index.ts
Normal file
14
src/lib/apis/tasks/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export const checkActiveChats = async (token: string, chatIds: string[]) => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/tasks/active/chats`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ chat_ids: chatIds })
|
||||
});
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
};
|
||||
|
|
@ -300,10 +300,11 @@ export const updateUserSettings = async (token: string, settings: object) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getUserById = async (token: string, userId: string) => {
|
||||
|
||||
export const getUserInfoById = async (token: string, userId: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}`, {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/info`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { marked } from 'marked';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
let startX = 0,
|
||||
startY = 0;
|
||||
let moved = false;
|
||||
let closeButtonElement: HTMLButtonElement;
|
||||
const DRAG_THRESHOLD_PX = 6;
|
||||
|
||||
const clickHandler = () => {
|
||||
|
|
@ -22,6 +24,10 @@
|
|||
dispatch('closeToast');
|
||||
};
|
||||
|
||||
const closeHandler = () => {
|
||||
dispatch('closeToast');
|
||||
};
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
|
|
@ -43,6 +49,11 @@
|
|||
// Release capture if taken
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
|
||||
|
||||
// Skip if clicking the close button
|
||||
if (closeButtonElement && (e.target === closeButtonElement || closeButtonElement.contains(e.target as Node))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only treat as a click if there wasn't a drag
|
||||
if (!moved) {
|
||||
clickHandler();
|
||||
|
|
@ -71,7 +82,7 @@
|
|||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
|
||||
class="group relative flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
|
||||
on:dragstart|preventDefault
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
|
|
@ -84,6 +95,16 @@
|
|||
}
|
||||
}}
|
||||
>
|
||||
<!-- Close button (visible on hover) -->
|
||||
<button
|
||||
bind:this={closeButtonElement}
|
||||
class="absolute -top-0.5 -left-0.5 p-0.5 rounded-full opacity-0 group-hover:opacity-100 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-opacity z-10"
|
||||
on:click|stopPropagation={closeHandler}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<XMark className="size-3" />
|
||||
</button>
|
||||
|
||||
<div class="shrink-0 self-top -translate-y-0.5">
|
||||
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-6 rounded-full" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
import Pencil from '$lib/components/icons/Pencil.svelte';
|
||||
import User from '$lib/components/icons/User.svelte';
|
||||
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
|
||||
import EditGroupModal from './EditGroupModal.svelte';
|
||||
|
||||
export let group = {
|
||||
|
|
@ -70,9 +69,6 @@
|
|||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 w-full font-medium flex-1">
|
||||
<div>
|
||||
<UserCircleSolid className="size-4" />
|
||||
</div>
|
||||
<div class="line-clamp-1">
|
||||
{group.name}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,13 +15,32 @@
|
|||
import AddMembersModal from './ChannelInfoModal/AddMembersModal.svelte';
|
||||
|
||||
export let show = false;
|
||||
export let channel = null;
|
||||
export let channel: any = null;
|
||||
|
||||
export let onUpdate = () => {};
|
||||
|
||||
let showAddMembersModal = false;
|
||||
const submitHandler = async () => {};
|
||||
|
||||
const hasPublicReadGrant = (grants: any) =>
|
||||
Array.isArray(grants) &&
|
||||
grants.some(
|
||||
(grant) =>
|
||||
grant?.principal_type === 'user' &&
|
||||
grant?.principal_id === '*' &&
|
||||
grant?.permission === 'read'
|
||||
);
|
||||
|
||||
const isPublicChannel = (channel: any): boolean => {
|
||||
if (channel?.type === 'group') {
|
||||
if (typeof channel?.is_private === 'boolean') {
|
||||
return !channel.is_private;
|
||||
}
|
||||
return hasPublicReadGrant(channel?.access_grants);
|
||||
}
|
||||
return hasPublicReadGrant(channel?.access_grants);
|
||||
};
|
||||
|
||||
const removeMemberHandler = async (userId) => {
|
||||
const res = await removeMembersById(localStorage.token, channel.id, {
|
||||
user_ids: [userId]
|
||||
|
|
@ -62,7 +81,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class=" size-4 justify-center flex items-center">
|
||||
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
|
||||
{#if isPublicChannel(channel)}
|
||||
<Hashtag className="size-3.5" strokeWidth="2.5" />
|
||||
{:else}
|
||||
<Lock className="size-5.5" strokeWidth="2" />
|
||||
|
|
|
|||
|
|
@ -129,6 +129,25 @@
|
|||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', keydownListener);
|
||||
});
|
||||
|
||||
const hasPublicReadGrant = (grants: any) =>
|
||||
Array.isArray(grants) &&
|
||||
grants.some(
|
||||
(grant) =>
|
||||
grant?.principal_type === 'user' &&
|
||||
grant?.principal_id === '*' &&
|
||||
grant?.permission === 'read'
|
||||
);
|
||||
|
||||
const isPublicChannel = (channel: any): boolean => {
|
||||
if (channel?.type === 'group') {
|
||||
if (typeof channel?.is_private === 'boolean') {
|
||||
return !channel.is_private;
|
||||
}
|
||||
return hasPublicReadGrant(channel?.access_grants);
|
||||
}
|
||||
return hasPublicReadGrant(channel?.access_grants);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if filteredItems.length}
|
||||
|
|
@ -165,7 +184,7 @@
|
|||
>
|
||||
{#if item.type === 'channel'}
|
||||
<div class=" size-4 justify-center flex items-center mr-0.5">
|
||||
{#if item?.data?.access_control === null}
|
||||
{#if isPublicChannel(item?.data)}
|
||||
<Hashtag className="size-3" strokeWidth="2.5" />
|
||||
{:else}
|
||||
<Lock className="size-[15px]" strokeWidth="2" />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { LinkPreview } from 'bits-ui';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
import { getUserById } from '$lib/apis/users';
|
||||
import { getUserInfoById } from '$lib/apis/users';
|
||||
|
||||
import UserStatus from './UserStatus.svelte';
|
||||
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
let user = null;
|
||||
onMount(async () => {
|
||||
if (id) {
|
||||
user = await getUserById(localStorage.token, id).catch((error) => {
|
||||
user = await getUserInfoById(localStorage.token, id).catch((error) => {
|
||||
console.error('Error fetching user by ID:', error);
|
||||
return null;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,25 @@
|
|||
let showChannelPinnedMessagesModal = false;
|
||||
let showChannelInfoModal = false;
|
||||
|
||||
const hasPublicReadGrant = (grants: any) =>
|
||||
Array.isArray(grants) &&
|
||||
grants.some(
|
||||
(grant) =>
|
||||
grant?.principal_type === 'user' &&
|
||||
grant?.principal_id === '*' &&
|
||||
grant?.permission === 'read'
|
||||
);
|
||||
|
||||
const isPublicChannel = (channel: any): boolean => {
|
||||
if (channel?.type === 'group') {
|
||||
if (typeof channel?.is_private === 'boolean') {
|
||||
return !channel.is_private;
|
||||
}
|
||||
return hasPublicReadGrant(channel?.access_grants);
|
||||
}
|
||||
return hasPublicReadGrant(channel?.access_grants);
|
||||
};
|
||||
|
||||
export let channel;
|
||||
|
||||
export let onPin = (messageId, pinned) => {};
|
||||
|
|
@ -112,7 +131,7 @@
|
|||
{/if}
|
||||
{:else}
|
||||
<div class=" size-4.5 justify-center flex items-center">
|
||||
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
|
||||
{#if isPublicChannel(channel)}
|
||||
<Hashtag className="size-3.5" strokeWidth="2.5" />
|
||||
{:else}
|
||||
<Lock className="size-5" strokeWidth="2" />
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@
|
|||
return {
|
||||
...file,
|
||||
user: undefined,
|
||||
access_control: undefined
|
||||
access_grants: undefined
|
||||
};
|
||||
}),
|
||||
selectedToolIds,
|
||||
|
|
@ -1634,7 +1634,7 @@
|
|||
<div class="size-4 items-center flex justify-center">
|
||||
<img
|
||||
src={filter.icon}
|
||||
class="size-3.5 {filter.icon.includes('svg')
|
||||
class="size-3.5 {filter.icon.includes('data:image/svg')
|
||||
? 'dark:invert-[80%]'
|
||||
: ''}"
|
||||
style="fill: currentColor;"
|
||||
|
|
@ -1746,7 +1746,7 @@
|
|||
<!-- {$i18n.t('Create Note')} -->
|
||||
<Tooltip content={$i18n.t('Create note')} className=" flex items-center">
|
||||
<button
|
||||
id="send-message-button"
|
||||
id="create-note-button"
|
||||
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center"
|
||||
type="button"
|
||||
disabled={prompt === '' && files.length === 0}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@
|
|||
<div class="size-4 items-center flex justify-center">
|
||||
<img
|
||||
src={filter.icon}
|
||||
class="size-3.5 {filter.icon.includes('svg')
|
||||
class="size-3.5 {filter.icon.includes('data:image/svg')
|
||||
? 'dark:invert-[80%]'
|
||||
: ''}"
|
||||
style="fill: currentColor;"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
import KatexRenderer from './KatexRenderer.svelte';
|
||||
import AlertRenderer, { alertComponent } from './AlertRenderer.svelte';
|
||||
import Collapsible from '$lib/components/common/Collapsible.svelte';
|
||||
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
|
||||
|
|
@ -134,6 +135,7 @@
|
|||
<div class="scrollbar-hidden relative overflow-x-auto max-w-full">
|
||||
<table
|
||||
class=" w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full rounded-xl"
|
||||
dir="auto"
|
||||
>
|
||||
<thead
|
||||
class="text-xs text-gray-700 uppercase bg-white dark:bg-gray-900 dark:text-gray-400 border-none"
|
||||
|
|
@ -322,7 +324,15 @@
|
|||
.replace(/<summary>.*?<\/summary>/gi, '')
|
||||
.trim()}
|
||||
|
||||
{#if textContent.length > 0}
|
||||
{#if token?.attributes?.type === 'tool_calls'}
|
||||
<!-- Tool calls have dedicated handling with ToolCallDisplay component -->
|
||||
<ToolCallDisplay
|
||||
id={`${id}-${tokenIdx}-tc`}
|
||||
attributes={token.attributes}
|
||||
open={false}
|
||||
className="w-full space-y-1"
|
||||
/>
|
||||
{:else if textContent.length > 0}
|
||||
<Collapsible
|
||||
title={token.summary}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
|
|
|
|||
|
|
@ -1397,7 +1397,7 @@
|
|||
<div class="size-4">
|
||||
<img
|
||||
src={action.icon}
|
||||
class="w-4 h-4 {action.icon.includes('svg')
|
||||
class="w-4 h-4 {action.icon.includes('data:image/svg')
|
||||
? 'dark:invert-[80%]'
|
||||
: ''}"
|
||||
style="fill: currentColor;"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
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 { getUserById } from '$lib/apis/users';
|
||||
import User from '$lib/components/icons/User.svelte';
|
||||
import UserProfileImage from './Account/UserProfileImage.svelte';
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
import { toast } from 'svelte-sonner';
|
||||
|
||||
export let chatId = '';
|
||||
export let disabled = false;
|
||||
let tags = [];
|
||||
|
||||
const getTags = async () => {
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
|
||||
<Tags
|
||||
{tags}
|
||||
{disabled}
|
||||
suggestionTags={$_tags ?? []}
|
||||
on:delete={(e) => {
|
||||
deleteTag(e.detail);
|
||||
|
|
|
|||
|
|
@ -35,11 +35,6 @@
|
|||
import ChevronUp from '../icons/ChevronUp.svelte';
|
||||
import ChevronDown from '../icons/ChevronDown.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import CodeBlock from '../chat/Messages/CodeBlock.svelte';
|
||||
import Markdown from '../chat/Messages/Markdown.svelte';
|
||||
import Image from './Image.svelte';
|
||||
import FullHeightIframe from './FullHeightIframe.svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
export let open = false;
|
||||
|
||||
|
|
@ -62,162 +57,9 @@
|
|||
$: onChange(open);
|
||||
|
||||
const collapsibleId = uuidv4();
|
||||
|
||||
function parseJSONString(str) {
|
||||
try {
|
||||
return parseJSONString(JSON.parse(str));
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJSONString(str) {
|
||||
try {
|
||||
const parsed = parseJSONString(str);
|
||||
// If parsed is an object/array, then it's valid JSON
|
||||
if (typeof parsed === 'object') {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} else {
|
||||
// It's a primitive value like a number, boolean, etc.
|
||||
return `${JSON.stringify(String(parsed))}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not valid JSON, return as-is
|
||||
return str;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {id} class={className}>
|
||||
{#if attributes?.type === 'tool_calls'}
|
||||
{@const args = decode(attributes?.arguments)}
|
||||
{@const result = decode(attributes?.result ?? '')}
|
||||
{@const files = parseJSONString(decode(attributes?.files ?? ''))}
|
||||
{@const embeds = parseJSONString(decode(attributes?.embeds ?? ''))}
|
||||
|
||||
{#if embeds && Array.isArray(embeds) && embeds.length > 0}
|
||||
<div class="py-1 w-full cursor-pointer">
|
||||
<div class=" w-full text-xs text-gray-500">
|
||||
<div class="">
|
||||
{attributes.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each embeds as embed, idx}
|
||||
<div class="my-2" id={`${collapsibleId}-tool-calls-${attributes?.id}-embed-${idx}`}>
|
||||
<FullHeightIframe
|
||||
src={embed}
|
||||
{args}
|
||||
allowScripts={true}
|
||||
allowForms={true}
|
||||
allowSameOrigin={true}
|
||||
allowPopups={true}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="{buttonClassName} cursor-pointer"
|
||||
on:pointerup={() => {
|
||||
if (!disabled) {
|
||||
open = !open;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
|
||||
attributes?.done !== 'true'
|
||||
? 'shimmer'
|
||||
: ''}
|
||||
"
|
||||
>
|
||||
{#if attributes?.done && attributes?.done !== 'true'}
|
||||
<div>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="">
|
||||
{#if attributes?.done === 'true'}
|
||||
<Markdown
|
||||
id={`${collapsibleId}-tool-calls-${attributes?.id}`}
|
||||
content={$i18n.t('View Result from **{{NAME}}**', {
|
||||
NAME: attributes.name
|
||||
})}
|
||||
/>
|
||||
{:else}
|
||||
<Markdown
|
||||
id={`${collapsibleId}-tool-calls-${attributes?.id}-executing`}
|
||||
content={$i18n.t('Executing **{{NAME}}**...', {
|
||||
NAME: attributes.name
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[1px]">
|
||||
{#if open}
|
||||
<ChevronUp strokeWidth="3.5" className="size-3.5" />
|
||||
{:else}
|
||||
<ChevronDown strokeWidth="3.5" className="size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !grow}
|
||||
{#if open && !hide}
|
||||
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
|
||||
{#if attributes?.type === 'tool_calls'}
|
||||
{#if attributes?.done === 'true'}
|
||||
<Markdown
|
||||
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
|
||||
content={`> \`\`\`json
|
||||
> ${formatJSONString(args)}
|
||||
> ${formatJSONString(result)}
|
||||
> \`\`\``}
|
||||
/>
|
||||
{:else}
|
||||
<Markdown
|
||||
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
|
||||
content={`> \`\`\`json
|
||||
> ${formatJSONString(args)}
|
||||
> \`\`\``}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<slot name="content" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if attributes?.done === 'true'}
|
||||
{#if typeof files === 'object'}
|
||||
{#each files ?? [] as file, idx}
|
||||
{#if typeof file === 'string'}
|
||||
{#if file.startsWith('data:image/')}
|
||||
<Image
|
||||
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
|
||||
src={file}
|
||||
alt="Image"
|
||||
/>
|
||||
{/if}
|
||||
{:else if typeof file === 'object'}
|
||||
{#if (file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) && file.url}
|
||||
<Image
|
||||
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
|
||||
src={file.url}
|
||||
alt="Image"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
{#if title !== null}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
|
|
@ -333,5 +175,4 @@
|
|||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -897,6 +897,34 @@
|
|||
oncompositionend(event);
|
||||
return false;
|
||||
},
|
||||
beforeinput: (view, event) => {
|
||||
// Workaround for Gboard's clipboard suggestion strip which sends
|
||||
// multi-line pastes as 'insertText' rather than a standard paste event.
|
||||
// Manually insert with hard breaks to preserve multi-line formatting.
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
if (isAndroid && event.inputType === 'insertText' && event.data?.includes('\n')) {
|
||||
event.preventDefault();
|
||||
|
||||
const { state, dispatch } = view;
|
||||
const { from, to } = state.selection;
|
||||
const lines = event.data.split('\n');
|
||||
const nodes = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (index > 0) {
|
||||
nodes.push(state.schema.nodes.hardBreak.create());
|
||||
}
|
||||
if (line.length > 0) {
|
||||
nodes.push(state.schema.text(line));
|
||||
}
|
||||
});
|
||||
|
||||
const fragment = Fragment.fromArray(nodes);
|
||||
dispatch(state.tr.replaceWith(from, to, fragment).scrollIntoView());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
focus: (view, event) => {
|
||||
eventDispatch('focus', { event });
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
export let tags = [];
|
||||
export let suggestionTags = [];
|
||||
export let disabled = false;
|
||||
|
||||
let inputValue = '';
|
||||
|
||||
|
|
@ -22,22 +23,25 @@
|
|||
<div class="flex flex-wrap items-center gap-1 w-full">
|
||||
<TagList
|
||||
{tags}
|
||||
{disabled}
|
||||
on:delete={(e) => {
|
||||
dispatch('delete', e.detail);
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
bind:value={inputValue}
|
||||
class="flex-1 min-w-24 {tags.length > 0
|
||||
? 'px-0.5'
|
||||
: ''} text-xs bg-transparent outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
placeholder={$i18n.t('Add a tag...')}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if !disabled}
|
||||
<input
|
||||
bind:value={inputValue}
|
||||
class="flex-1 min-w-24 {tags.length > 0
|
||||
? 'px-0.5'
|
||||
: ''} text-xs bg-transparent outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
placeholder={$i18n.t('Add a tag...')}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
const i18n = getContext('i18n');
|
||||
|
||||
export let suggestionTags = [];
|
||||
export let disabled = false;
|
||||
|
||||
let tagName = '';
|
||||
let showInput = false;
|
||||
|
|
@ -30,7 +31,9 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
{#if showInput}
|
||||
{#if disabled}
|
||||
<!-- hidden when disabled -->
|
||||
{:else if showInput}
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-200/80 dark:bg-gray-700">
|
||||
<span class="text-gray-500 dark:text-blue-400">+</span>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -6,18 +6,27 @@
|
|||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
export let tag;
|
||||
export let disabled = false;
|
||||
export let onDelete = () => {};
|
||||
</script>
|
||||
|
||||
{#if tag}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-1.5 py-[1px] rounded-full bg-gray-100/50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800 text-gray-600 dark:text-gray-300 text-xs font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<span class="line-clamp-1">{tag.name}</span>
|
||||
<XMark className="size-3" strokeWidth="2.5" />
|
||||
</button>
|
||||
{#if disabled}
|
||||
<span
|
||||
class="flex items-center gap-1 px-1.5 py-[1px] rounded-full bg-gray-100/50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800 text-gray-600 dark:text-gray-300 text-xs font-medium"
|
||||
>
|
||||
<span class="line-clamp-1">{tag.name}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-1.5 py-[1px] rounded-full bg-gray-100/50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800 text-gray-600 dark:text-gray-300 text-xs font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<span class="line-clamp-1">{tag.name}</span>
|
||||
<XMark className="size-3" strokeWidth="2.5" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@
|
|||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let tags = [];
|
||||
export let disabled = false;
|
||||
</script>
|
||||
|
||||
{#each tags as tag}
|
||||
<TagItem
|
||||
{tag}
|
||||
{disabled}
|
||||
onDelete={() => {
|
||||
dispatch('delete', tag.name);
|
||||
}}
|
||||
|
|
|
|||
186
src/lib/components/common/ToolCallDisplay.svelte
Normal file
186
src/lib/components/common/ToolCallDisplay.svelte
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<script lang="ts">
|
||||
import { decode } from 'html-entities';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
import ChevronUp from '../icons/ChevronUp.svelte';
|
||||
import ChevronDown from '../icons/ChevronDown.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import Markdown from '../chat/Messages/Markdown.svelte';
|
||||
import Image from './Image.svelte';
|
||||
import FullHeightIframe from './FullHeightIframe.svelte';
|
||||
|
||||
export let id: string = '';
|
||||
export let attributes: {
|
||||
type?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
result?: string;
|
||||
files?: string;
|
||||
embeds?: string;
|
||||
done?: string;
|
||||
} = {};
|
||||
|
||||
export let open = false;
|
||||
export let className = '';
|
||||
export let buttonClassName =
|
||||
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
|
||||
|
||||
const componentId = id || uuidv4();
|
||||
|
||||
function parseJSONString(str: string) {
|
||||
try {
|
||||
return parseJSONString(JSON.parse(str));
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJSONString(str: string) {
|
||||
try {
|
||||
const parsed = parseJSONString(str);
|
||||
// If parsed is an object/array, then it's valid JSON
|
||||
if (typeof parsed === 'object') {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} else {
|
||||
// It's a primitive value like a number, boolean, etc.
|
||||
return `${JSON.stringify(String(parsed))}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not valid JSON, return as-is
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
// Decode and parse attributes
|
||||
$: args = decode(attributes?.arguments ?? '');
|
||||
$: result = decode(attributes?.result ?? '');
|
||||
$: files = parseJSONString(decode(attributes?.files ?? ''));
|
||||
$: embeds = parseJSONString(decode(attributes?.embeds ?? ''));
|
||||
$: isDone = attributes?.done === 'true';
|
||||
$: isExecuting = attributes?.done && attributes?.done !== 'true';
|
||||
</script>
|
||||
|
||||
<div {id} class={className}>
|
||||
{#if embeds && Array.isArray(embeds) && embeds.length > 0}
|
||||
<!-- Embed Mode: Show iframes without collapsible behavior -->
|
||||
<div class="py-1 w-full cursor-pointer">
|
||||
<div class="w-full text-xs text-gray-500">
|
||||
<div class="">
|
||||
{attributes.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each embeds as embed, idx}
|
||||
<div class="my-2" id={`${componentId}-tool-call-embed-${idx}`}>
|
||||
<FullHeightIframe
|
||||
src={embed}
|
||||
{args}
|
||||
allowScripts={true}
|
||||
allowForms={true}
|
||||
allowSameOrigin={true}
|
||||
allowPopups={true}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Standard collapsible tool call display -->
|
||||
<div
|
||||
class="{buttonClassName} cursor-pointer"
|
||||
on:pointerup={() => {
|
||||
open = !open;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full font-medium flex items-center justify-between gap-2 {isExecuting
|
||||
? 'shimmer'
|
||||
: ''}"
|
||||
>
|
||||
{#if isExecuting}
|
||||
<div>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="">
|
||||
{#if isDone}
|
||||
<Markdown
|
||||
id={`${componentId}-tool-call-title`}
|
||||
content={$i18n.t('View Result from **{{NAME}}**', {
|
||||
NAME: attributes.name
|
||||
})}
|
||||
/>
|
||||
{:else}
|
||||
<Markdown
|
||||
id={`${componentId}-tool-call-executing`}
|
||||
content={$i18n.t('Executing **{{NAME}}**...', {
|
||||
NAME: attributes.name
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[1px]">
|
||||
{#if open}
|
||||
<ChevronUp strokeWidth="3.5" className="size-3.5" />
|
||||
{:else}
|
||||
<ChevronDown strokeWidth="3.5" className="size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
|
||||
{#if isDone}
|
||||
<Markdown
|
||||
id={`${componentId}-tool-call-result`}
|
||||
content={`> \`\`\`json
|
||||
> ${formatJSONString(args)}
|
||||
> ${formatJSONString(result)}
|
||||
> \`\`\``}
|
||||
/>
|
||||
{:else}
|
||||
<Markdown
|
||||
id={`${componentId}-tool-call-args`}
|
||||
content={`> \`\`\`json
|
||||
> ${formatJSONString(args)}
|
||||
> \`\`\``}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Files display (images etc.) when done -->
|
||||
{#if isDone}
|
||||
{#if typeof files === 'object'}
|
||||
{#each files ?? [] as file, idx}
|
||||
{#if typeof file === 'string'}
|
||||
{#if file.startsWith('data:image/')}
|
||||
<Image
|
||||
id={`${componentId}-tool-call-result-${idx}`}
|
||||
src={file}
|
||||
alt="Image"
|
||||
/>
|
||||
{/if}
|
||||
{:else if typeof file === 'object'}
|
||||
{#if (file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) && file.url}
|
||||
<Image
|
||||
id={`${componentId}-tool-call-result-${idx}`}
|
||||
src={file.url}
|
||||
alt="Image"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -26,7 +26,8 @@
|
|||
models,
|
||||
selectedFolder,
|
||||
WEBUI_NAME,
|
||||
sidebarWidth
|
||||
sidebarWidth,
|
||||
activeChatIds
|
||||
} from '$lib/stores';
|
||||
import { onMount, getContext, tick, onDestroy } from 'svelte';
|
||||
|
||||
|
|
@ -42,6 +43,7 @@
|
|||
importChats
|
||||
} from '$lib/apis/chats';
|
||||
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
|
||||
import { checkActiveChats } from '$lib/apis/tasks';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
import ArchivedChatsModal from './ArchivedChatsModal.svelte';
|
||||
|
|
@ -469,6 +471,17 @@
|
|||
await initChannels();
|
||||
}
|
||||
await initChatList();
|
||||
|
||||
// Check which chats have active tasks
|
||||
const allChatIds = [...$chats.map((c) => c.id), ...$pinnedChats.map((c) => c.id)];
|
||||
if (allChatIds.length > 0) {
|
||||
try {
|
||||
const res = await checkActiveChats(localStorage.token, allChatIds);
|
||||
activeChatIds.set(new Set(res.active_chat_ids || []));
|
||||
} catch (e) {
|
||||
console.debug('Failed to check active chats:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
settings.subscribe((value) => {
|
||||
|
|
@ -493,8 +506,32 @@
|
|||
dropZone?.addEventListener('dragover', onDragOver);
|
||||
dropZone?.addEventListener('drop', onDrop);
|
||||
dropZone?.addEventListener('dragleave', onDragLeave);
|
||||
|
||||
// Listen for real-time chat:active events via the events channel
|
||||
$socket?.off('events', chatActiveEventHandler);
|
||||
$socket?.on('events', chatActiveEventHandler);
|
||||
});
|
||||
|
||||
// Handler for chat:active events (defined outside onMount for proper cleanup)
|
||||
const chatActiveEventHandler = (event: {
|
||||
chat_id: string;
|
||||
message_id: string;
|
||||
data: { type: string; data: any };
|
||||
}) => {
|
||||
if (event.data?.type === 'chat:active') {
|
||||
const { active } = event.data.data;
|
||||
activeChatIds.update((ids) => {
|
||||
const newSet = new Set(ids);
|
||||
if (active) {
|
||||
newSet.add(event.chat_id);
|
||||
} else {
|
||||
newSet.delete(event.chat_id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribers && unsubscribers.length > 0) {
|
||||
unsubscribers.forEach((unsubscriber) => {
|
||||
|
|
@ -518,6 +555,9 @@
|
|||
dropZone?.removeEventListener('dragover', onDragOver);
|
||||
dropZone?.removeEventListener('drop', onDrop);
|
||||
dropZone?.removeEventListener('dragleave', onDragLeave);
|
||||
|
||||
// Clean up socket listener
|
||||
$socket?.off('events', chatActiveEventHandler);
|
||||
});
|
||||
|
||||
const newChatHandler = async () => {
|
||||
|
|
@ -560,7 +600,8 @@
|
|||
|
||||
<ChannelModal
|
||||
bind:show={showCreateChannel}
|
||||
onSubmit={async ({ type, name, is_private, access_control, group_ids, user_ids }) => {
|
||||
onSubmit={async (payload: any) => {
|
||||
let { type, name, is_private, access_grants, group_ids, user_ids } = payload ?? {};
|
||||
name = name?.trim();
|
||||
|
||||
if (type === 'dm') {
|
||||
|
|
@ -579,7 +620,7 @@
|
|||
type: type,
|
||||
name: name,
|
||||
is_private: is_private,
|
||||
access_control: access_control,
|
||||
access_grants: access_grants,
|
||||
group_ids: group_ids,
|
||||
user_ids: user_ids
|
||||
}).catch((error) => {
|
||||
|
|
@ -815,9 +856,6 @@
|
|||
{#if $config?.features?.enable_user_status}
|
||||
<div class="absolute -bottom-0.5 -right-0.5">
|
||||
<span class="relative flex size-2.5">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
|
||||
></span>
|
||||
<span
|
||||
class="relative inline-flex size-2.5 rounded-full {true
|
||||
? 'bg-green-500'
|
||||
|
|
@ -1247,6 +1285,7 @@
|
|||
className=""
|
||||
id={chat.id}
|
||||
title={chat.title}
|
||||
createdAt={chat.created_at}
|
||||
{shiftKey}
|
||||
selected={selectedChatId === chat.id}
|
||||
on:select={() => {
|
||||
|
|
@ -1307,6 +1346,7 @@
|
|||
className=""
|
||||
id={chat.id}
|
||||
title={chat.title}
|
||||
createdAt={chat.created_at}
|
||||
{shiftKey}
|
||||
selected={selectedChatId === chat.id}
|
||||
on:select={() => {
|
||||
|
|
@ -1384,9 +1424,6 @@
|
|||
{#if $config?.features?.enable_user_status}
|
||||
<div class="absolute -bottom-0.5 -right-0.5">
|
||||
<span class="relative flex size-2.5">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
|
||||
></span>
|
||||
<span
|
||||
class="relative inline-flex size-2.5 rounded-full {true
|
||||
? 'bg-green-500'
|
||||
|
|
|
|||
|
|
@ -25,6 +25,25 @@
|
|||
let showEditChannelModal = false;
|
||||
|
||||
let itemElement;
|
||||
|
||||
const hasPublicReadGrant = (grants: any) =>
|
||||
Array.isArray(grants) &&
|
||||
grants.some(
|
||||
(grant) =>
|
||||
grant?.principal_type === 'user' &&
|
||||
grant?.principal_id === '*' &&
|
||||
grant?.permission === 'read'
|
||||
);
|
||||
|
||||
const isPublicChannel = (channel: any): boolean => {
|
||||
if (channel?.type === 'group') {
|
||||
if (typeof channel?.is_private === 'boolean') {
|
||||
return !channel.is_private;
|
||||
}
|
||||
return hasPublicReadGrant(channel?.access_grants);
|
||||
}
|
||||
return hasPublicReadGrant(channel?.access_grants);
|
||||
};
|
||||
</script>
|
||||
|
||||
<ChannelModal
|
||||
|
|
@ -32,11 +51,12 @@
|
|||
{channel}
|
||||
edit={true}
|
||||
{onUpdate}
|
||||
onSubmit={async ({ name, is_private, access_control, group_ids, user_ids }) => {
|
||||
onSubmit={async (payload: any) => {
|
||||
const { name, is_private, access_grants, group_ids, user_ids } = payload ?? {};
|
||||
const res = await updateChannelById(localStorage.token, channel.id, {
|
||||
name,
|
||||
is_private,
|
||||
access_control,
|
||||
access_grants,
|
||||
group_ids,
|
||||
user_ids
|
||||
}).catch((error) => {
|
||||
|
|
@ -123,7 +143,7 @@
|
|||
{/if}
|
||||
{:else}
|
||||
<div class=" size-4 justify-center flex items-center ml-1">
|
||||
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
|
||||
{#if isPublicChannel(channel)}
|
||||
<Hashtag className="size-3.5" strokeWidth="2.5" />
|
||||
{:else}
|
||||
<Lock className="size-[15px]" strokeWidth="2" />
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
export let onSubmit: Function = () => {};
|
||||
export let onUpdate: Function = () => {};
|
||||
|
||||
export let channel = null;
|
||||
export let channel: any = null;
|
||||
export let edit = false;
|
||||
|
||||
let channelTypes = ['group', 'dm'];
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
let name = '';
|
||||
|
||||
let isPrivate = null;
|
||||
let accessControl = {};
|
||||
let accessGrants = [];
|
||||
|
||||
let groupIds = [];
|
||||
let userIds = [];
|
||||
|
|
@ -65,8 +65,8 @@
|
|||
await onSubmit({
|
||||
type: type,
|
||||
name: name.replace(/\s/g, '-'),
|
||||
is_private: type === 'group' ? isPrivate : null,
|
||||
access_control: type === '' ? accessControl : {},
|
||||
is_private: type === 'group' ? (isPrivate ?? true) : null,
|
||||
access_grants: type === '' ? accessGrants : [],
|
||||
group_ids: groupIds,
|
||||
user_ids: userIds
|
||||
});
|
||||
|
|
@ -85,8 +85,12 @@
|
|||
|
||||
if (channel) {
|
||||
name = channel?.name ?? '';
|
||||
isPrivate = channel?.is_private ?? null;
|
||||
accessControl = channel.access_control;
|
||||
if (type === 'group') {
|
||||
isPrivate = typeof channel?.is_private === 'boolean' ? channel.is_private : true;
|
||||
} else {
|
||||
isPrivate = null;
|
||||
}
|
||||
accessGrants = channel?.access_grants ?? [];
|
||||
userIds = channel?.user_ids ?? [];
|
||||
}
|
||||
};
|
||||
|
|
@ -102,8 +106,14 @@
|
|||
|
||||
const deleteHandler = async () => {
|
||||
showDeleteConfirmDialog = false;
|
||||
if (!channel?.id) {
|
||||
show = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await deleteChannelById(localStorage.token, channel.id).catch((error) => {
|
||||
const channelId = channel.id;
|
||||
|
||||
const res = await deleteChannelById(localStorage.token, channelId).catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
|
||||
|
|
@ -111,7 +121,7 @@
|
|||
toast.success($i18n.t('Channel deleted successfully'));
|
||||
onUpdate();
|
||||
|
||||
if ($page.url.pathname === `/channels/${channel.id}`) {
|
||||
if ($page.url.pathname === `/channels/${channelId}`) {
|
||||
goto('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +132,7 @@
|
|||
const resetHandler = () => {
|
||||
type = '';
|
||||
name = '';
|
||||
accessControl = {};
|
||||
accessGrants = [];
|
||||
userIds = [];
|
||||
loading = false;
|
||||
};
|
||||
|
|
@ -226,11 +236,11 @@
|
|||
{#if type !== 'dm'}
|
||||
<div class="-mx-2 mb-1 mt-2.5 px-2">
|
||||
{#if type === ''}
|
||||
<AccessControl bind:accessControl accessRoles={['read', 'write']} />
|
||||
<AccessControl bind:accessGrants accessRoles={['read', 'write']} />
|
||||
{:else if type === 'group'}
|
||||
<Visibility
|
||||
state={isPrivate ? 'private' : 'public'}
|
||||
onChange={(value) => {
|
||||
onChange={(value: string) => {
|
||||
if (value === 'private') {
|
||||
isPrivate = true;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@
|
|||
showSidebar,
|
||||
currentChatPage,
|
||||
tags,
|
||||
selectedFolder
|
||||
selectedFolder,
|
||||
activeChatIds
|
||||
} from '$lib/stores';
|
||||
|
||||
import ChatMenu from './ChatMenu.svelte';
|
||||
|
|
@ -41,18 +42,40 @@
|
|||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Document from '$lib/components/icons/Document.svelte';
|
||||
import Sparkles from '$lib/components/icons/Sparkles.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import { generateTitle } from '$lib/apis';
|
||||
|
||||
export let className = '';
|
||||
|
||||
export let id;
|
||||
export let title;
|
||||
export let createdAt: number | null = null;
|
||||
|
||||
export let selected = false;
|
||||
export let shiftKey = false;
|
||||
|
||||
export let onDragEnd = () => {};
|
||||
|
||||
function formatTimeAgo(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp * 1000; // timestamp is in seconds
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const weeks = Math.floor(days / 7);
|
||||
const years = Math.floor(days / 365);
|
||||
|
||||
if (years > 0) return `${years}y`;
|
||||
if (weeks > 0) return `${weeks}w`;
|
||||
if (days > 0) return `${days}d`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return '1m';
|
||||
}
|
||||
|
||||
|
||||
let chat = null;
|
||||
|
||||
let mouseOver = false;
|
||||
|
|
@ -423,11 +446,25 @@
|
|||
on:focus={(e) => {}}
|
||||
draggable="false"
|
||||
>
|
||||
<div class=" flex self-center flex-1 w-full">
|
||||
<div dir="auto" class=" text-left self-center overflow-hidden w-full h-[20px] truncate">
|
||||
<!-- Loading spinner for active chat (left side) -->
|
||||
{#if $activeChatIds.has(id)}
|
||||
<div class="shrink-0 self-center pr-2">
|
||||
<Spinner className="size-3" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex self-center flex-1 w-full min-w-0">
|
||||
<div dir="auto" class="text-left self-center overflow-hidden w-full h-[20px] truncate">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time ago indicator -->
|
||||
{#if createdAt && !mouseOver}
|
||||
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
|
||||
{formatTimeAgo(createdAt)}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -643,6 +643,7 @@
|
|||
<ChatItem
|
||||
id={chat.id}
|
||||
title={chat.title}
|
||||
createdAt={chat.created_at}
|
||||
{shiftKey}
|
||||
on:change={(e) => {
|
||||
dispatch('change', e.detail);
|
||||
|
|
|
|||
|
|
@ -110,9 +110,6 @@
|
|||
{#if $user?.is_active ?? true}
|
||||
<div>
|
||||
<span class="relative flex size-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||
/>
|
||||
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -371,9 +368,6 @@
|
|||
>
|
||||
<div class=" flex items-center">
|
||||
<span class="relative flex size-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||
/>
|
||||
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -108,9 +108,18 @@
|
|||
},
|
||||
// pages: [], // TODO: Implement pages for notes to allow users to create multiple pages in a note
|
||||
meta: null,
|
||||
access_control: {}
|
||||
access_grants: []
|
||||
};
|
||||
|
||||
const hasPublicReadGrant = (grants) =>
|
||||
Array.isArray(grants) &&
|
||||
grants.some(
|
||||
(grant) =>
|
||||
grant?.principal_type === 'user' &&
|
||||
grant?.principal_id === '*' &&
|
||||
grant?.permission === 'read'
|
||||
);
|
||||
|
||||
let files = [];
|
||||
let messages = [];
|
||||
|
||||
|
|
@ -161,6 +170,9 @@
|
|||
|
||||
if (res) {
|
||||
note = res;
|
||||
if (!Array.isArray(note?.access_grants)) {
|
||||
note.access_grants = [];
|
||||
}
|
||||
files = res.data.files || [];
|
||||
|
||||
if (note?.write_access) {
|
||||
|
|
@ -193,7 +205,7 @@
|
|||
data: {
|
||||
files: files
|
||||
},
|
||||
access_control: note?.access_control
|
||||
access_grants: note?.access_grants ?? []
|
||||
}).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
});
|
||||
|
|
@ -765,8 +777,8 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
console.log('noteEventHandler', _note);
|
||||
if (_note.id !== id) return;
|
||||
|
||||
if (_note.access_control && _note.access_control !== note.access_control) {
|
||||
note.access_control = _note.access_control;
|
||||
if (_note.access_grants && _note.access_grants !== note.access_grants) {
|
||||
note.access_grants = _note.access_grants;
|
||||
}
|
||||
|
||||
if (_note.data && _note.data.files) {
|
||||
|
|
@ -851,7 +863,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
{#if note}
|
||||
<AccessControlModal
|
||||
bind:show={showAccessControlModal}
|
||||
bind:accessControl={note.access_control}
|
||||
bind:accessGrants={note.access_grants}
|
||||
accessRoles={['read', 'write']}
|
||||
onChange={() => {
|
||||
changeDebounceHandler();
|
||||
|
|
@ -1114,7 +1126,11 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
}}
|
||||
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
|
||||
>
|
||||
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
|
||||
<span>
|
||||
{hasPublicReadGrant(note?.access_grants)
|
||||
? $i18n.t('Everyone')
|
||||
: $i18n.t('Private')}
|
||||
</span>
|
||||
</button>
|
||||
{:else}
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@
|
|||
}
|
||||
},
|
||||
meta: null,
|
||||
access_control: {}
|
||||
access_grants: []
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export const createNoteHandler = async (title: string, md?: string, html?: strin
|
|||
}
|
||||
},
|
||||
meta: null,
|
||||
access_control: {}
|
||||
access_grants: []
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
let name = '';
|
||||
let description = '';
|
||||
let accessControl = {};
|
||||
let accessGrants = [];
|
||||
|
||||
const submitHandler = async () => {
|
||||
loading = true;
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
localStorage.token,
|
||||
name,
|
||||
description,
|
||||
accessControl
|
||||
accessGrants
|
||||
).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
});
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
|
||||
<div class="mt-2">
|
||||
<AccessControl
|
||||
bind:accessControl
|
||||
bind:accessGrants
|
||||
accessRoles={['read', 'write']}
|
||||
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@
|
|||
file_ids: string[];
|
||||
};
|
||||
files: any[];
|
||||
access_grants?: any[];
|
||||
write_access?: boolean;
|
||||
};
|
||||
|
||||
let id = null;
|
||||
|
|
@ -614,7 +616,7 @@
|
|||
...knowledge,
|
||||
name: knowledge.name,
|
||||
description: knowledge.description,
|
||||
access_control: knowledge.access_control
|
||||
access_grants: knowledge.access_grants ?? []
|
||||
}).catch((e) => {
|
||||
toast.error(`${e}`);
|
||||
});
|
||||
|
|
@ -745,6 +747,9 @@
|
|||
|
||||
if (res) {
|
||||
knowledge = res;
|
||||
if (!Array.isArray(knowledge?.access_grants)) {
|
||||
knowledge.access_grants = [];
|
||||
}
|
||||
knowledgeId = knowledge?.id;
|
||||
} else {
|
||||
goto('/workspace/knowledge');
|
||||
|
|
@ -828,9 +833,11 @@
|
|||
{#if id && knowledge}
|
||||
<AccessControlModal
|
||||
bind:show={showAccessControlModal}
|
||||
bind:accessControl={knowledge.access_control}
|
||||
bind:accessGrants={knowledge.access_grants}
|
||||
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_knowledge ||
|
||||
$user?.role === 'admin' ||
|
||||
knowledge?.write_access}
|
||||
onChange={() => {
|
||||
changeDebounceHandler();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@
|
|||
let builtinTools = {};
|
||||
|
||||
let actionIds = [];
|
||||
let accessControl = {};
|
||||
let accessGrants = [];
|
||||
let tts = { voice: '' };
|
||||
|
||||
const submitHandler = async () => {
|
||||
|
|
@ -140,7 +140,7 @@
|
|||
|
||||
info.params = { ...info.params, ...params };
|
||||
|
||||
info.access_control = accessControl;
|
||||
info.access_grants = accessGrants;
|
||||
info.meta.capabilities = capabilities;
|
||||
|
||||
if (enableDescription) {
|
||||
|
|
@ -301,14 +301,7 @@
|
|||
builtinTools = model?.meta?.builtinTools ?? {};
|
||||
tts = { voice: model?.meta?.tts?.voice ?? '' };
|
||||
|
||||
if ('access_control' in model) {
|
||||
accessControl = model.access_control;
|
||||
} else {
|
||||
accessControl = {};
|
||||
}
|
||||
|
||||
console.log(model?.access_control);
|
||||
console.log(accessControl);
|
||||
accessGrants = model?.access_grants ?? [];
|
||||
|
||||
info = {
|
||||
...info,
|
||||
|
|
@ -334,10 +327,10 @@
|
|||
{#if loaded}
|
||||
<AccessControlModal
|
||||
bind:show={showAccessControlModal}
|
||||
bind:accessControl
|
||||
bind:accessGrants
|
||||
accessRoles={['read', 'write']}
|
||||
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin' || edit}
|
||||
/>
|
||||
|
||||
{#if onBack}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
let commitMessage = '';
|
||||
let isProduction = true;
|
||||
|
||||
let accessControl = {};
|
||||
let accessGrants = [];
|
||||
let showAccessControlModal = false;
|
||||
let hasManualEdit = false;
|
||||
|
||||
|
|
@ -87,7 +87,7 @@
|
|||
command,
|
||||
content,
|
||||
tags: tags.map((tag) => tag.name),
|
||||
access_control: accessControl,
|
||||
access_grants: accessGrants,
|
||||
commit_message: commitMessage || undefined,
|
||||
is_production: isProduction
|
||||
});
|
||||
|
|
@ -259,7 +259,7 @@
|
|||
command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command;
|
||||
content = prompt.content;
|
||||
tags = (prompt.tags || []).map((tag) => ({ name: tag }));
|
||||
accessControl = prompt?.access_control === undefined ? {} : prompt?.access_control;
|
||||
accessGrants = prompt?.access_grants === undefined ? [] : prompt?.access_grants;
|
||||
|
||||
// Store originals for revert on collision
|
||||
originalName = name;
|
||||
|
|
@ -286,10 +286,10 @@
|
|||
|
||||
<AccessControlModal
|
||||
bind:show={showAccessControlModal}
|
||||
bind:accessControl
|
||||
bind:accessGrants
|
||||
accessRoles={['read', 'write']}
|
||||
share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin' || edit}
|
||||
/>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
|
|
@ -435,6 +435,7 @@
|
|||
<div class="flex-1 min-w-0">
|
||||
<Tags
|
||||
{tags}
|
||||
{disabled}
|
||||
{suggestionTags}
|
||||
on:add={(e) => {
|
||||
tags = [...tags, { name: e.detail }];
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
description: ''
|
||||
};
|
||||
export let content = '';
|
||||
export let accessControl = {};
|
||||
export let accessGrants = [];
|
||||
|
||||
let _content = '';
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ class Tools:
|
|||
name,
|
||||
meta,
|
||||
content,
|
||||
access_control: accessControl
|
||||
access_grants: accessGrants
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -187,10 +187,10 @@ class Tools:
|
|||
|
||||
<AccessControlModal
|
||||
bind:show={showAccessControlModal}
|
||||
bind:accessControl
|
||||
bind:accessGrants
|
||||
accessRoles={['read', 'write']}
|
||||
share={$user?.permissions?.sharing?.tools || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin'}
|
||||
sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin' || edit}
|
||||
/>
|
||||
|
||||
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
|
||||
|
|
|
|||
|
|
@ -3,75 +3,430 @@
|
|||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { getGroups } from '$lib/apis/groups';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
|
||||
import { getGroups, getGroupById, getGroupInfoById } from '$lib/apis/groups';
|
||||
import { getUserInfoById } from '$lib/apis/users';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Badge from '$lib/components/common/Badge.svelte';
|
||||
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import AddAccessModal from './AddAccessModal.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
type AccessGrant = {
|
||||
id?: string;
|
||||
principal_type: 'user' | 'group';
|
||||
principal_id: string;
|
||||
permission: 'read' | 'write';
|
||||
};
|
||||
|
||||
type LegacyAccessControl = {
|
||||
read: { group_ids: string[]; user_ids: string[] };
|
||||
write: { group_ids: string[]; user_ids: string[] };
|
||||
};
|
||||
|
||||
export let onChange: Function = () => {};
|
||||
|
||||
export let accessRoles = ['read'];
|
||||
export let accessControl = {};
|
||||
export let accessGrants: AccessGrant[] | any = [];
|
||||
export let accessControl: any = undefined;
|
||||
|
||||
export let share = true;
|
||||
export let sharePublic = true;
|
||||
|
||||
let selectedGroupId = '';
|
||||
let groups = [];
|
||||
let groups: any[] = [];
|
||||
const resolvingGroupIds = new Set<string>();
|
||||
let userById: Record<string, any> = {};
|
||||
const resolvingUserIds = new Set<string>();
|
||||
|
||||
$: if (!sharePublic && accessControl === null) {
|
||||
initPublicAccess();
|
||||
}
|
||||
let showAddAccessModal = false;
|
||||
|
||||
const initPublicAccess = () => {
|
||||
if (!sharePublic && accessControl === null) {
|
||||
accessControl = {
|
||||
read: {
|
||||
group_ids: [],
|
||||
user_ids: []
|
||||
},
|
||||
write: {
|
||||
group_ids: [],
|
||||
user_ids: []
|
||||
const dedupeAccessGrants = (grants: AccessGrant[] | null | undefined): AccessGrant[] => {
|
||||
if (!Array.isArray(grants)) return [];
|
||||
const map = new Map<string, AccessGrant>();
|
||||
for (const grant of grants) {
|
||||
if (!grant) continue;
|
||||
const key = `${grant.principal_type}:${grant.principal_id}:${grant.permission}`;
|
||||
if (!grant.principal_type || !grant.principal_id || !grant.permission) continue;
|
||||
map.set(key, {
|
||||
id: grant.id,
|
||||
principal_type: grant.principal_type,
|
||||
principal_id: grant.principal_id,
|
||||
permission: grant.permission
|
||||
});
|
||||
}
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const legacyAccessControlToGrants = (accessControl: any): AccessGrant[] => {
|
||||
if (accessControl === null) {
|
||||
return [
|
||||
{
|
||||
principal_type: 'user',
|
||||
principal_id: '*',
|
||||
permission: 'read'
|
||||
}
|
||||
};
|
||||
onChange(accessControl);
|
||||
];
|
||||
}
|
||||
|
||||
if (!accessControl || typeof accessControl !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const grants: AccessGrant[] = [];
|
||||
for (const permission of ['read', 'write'] as const) {
|
||||
const entry = accessControl?.[permission] ?? {};
|
||||
for (const groupId of entry?.group_ids ?? []) {
|
||||
grants.push({
|
||||
principal_type: 'group',
|
||||
principal_id: groupId,
|
||||
permission
|
||||
});
|
||||
}
|
||||
for (const userId of entry?.user_ids ?? []) {
|
||||
grants.push({
|
||||
principal_type: 'user',
|
||||
principal_id: userId,
|
||||
permission
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeAccessGrants(grants);
|
||||
};
|
||||
|
||||
const grantsToLegacyAccessControl = (grants: AccessGrant[]): null | LegacyAccessControl => {
|
||||
const normalized = dedupeAccessGrants(grants);
|
||||
if (hasPublicReadGrant(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: LegacyAccessControl = {
|
||||
read: { group_ids: [], user_ids: [] },
|
||||
write: { group_ids: [], user_ids: [] }
|
||||
};
|
||||
|
||||
for (const grant of normalized) {
|
||||
if (!['read', 'write'].includes(grant.permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (grant.principal_type === 'group') {
|
||||
if (!result[grant.permission].group_ids.includes(grant.principal_id)) {
|
||||
result[grant.permission].group_ids = [
|
||||
...result[grant.permission].group_ids,
|
||||
grant.principal_id
|
||||
];
|
||||
}
|
||||
} else if (grant.principal_type === 'user' && grant.principal_id !== '*') {
|
||||
if (!result[grant.permission].user_ids.includes(grant.principal_id)) {
|
||||
result[grant.permission].user_ids = [
|
||||
...result[grant.permission].user_ids,
|
||||
grant.principal_id
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeInputToGrants = (value: any): AccessGrant[] => {
|
||||
if (value === null) {
|
||||
return legacyAccessControlToGrants(null);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return dedupeAccessGrants(value);
|
||||
}
|
||||
if (value && typeof value === 'object' && ('read' in value || 'write' in value)) {
|
||||
return legacyAccessControlToGrants(value);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const stableStringify = (value: any): string => {
|
||||
try {
|
||||
return JSON.stringify(value ?? null);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const hasPublicReadGrant = (grants: AccessGrant[]): boolean =>
|
||||
grants.some(
|
||||
(grant) =>
|
||||
grant.principal_type === 'user' && grant.principal_id === '*' && grant.permission === 'read'
|
||||
);
|
||||
|
||||
const currentGrants = (): AccessGrant[] =>
|
||||
Array.isArray(accessGrants) ? (accessGrants as AccessGrant[]) : [];
|
||||
|
||||
const getPrincipalIdsByPermission = (
|
||||
principalType: 'user' | 'group',
|
||||
permission: 'read' | 'write'
|
||||
): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
currentGrants()
|
||||
.filter(
|
||||
(grant) => grant.principal_type === principalType && grant.permission === permission
|
||||
)
|
||||
.map((grant) => grant.principal_id)
|
||||
)
|
||||
);
|
||||
|
||||
const hasPrincipalGrant = (
|
||||
principalType: 'user' | 'group',
|
||||
principalId: string,
|
||||
permission: 'read' | 'write'
|
||||
): boolean =>
|
||||
currentGrants().some(
|
||||
(grant) =>
|
||||
grant.principal_type === principalType &&
|
||||
grant.principal_id === principalId &&
|
||||
grant.permission === permission
|
||||
);
|
||||
|
||||
const commitAccessGrants = (nextGrants: AccessGrant[]) => {
|
||||
accessGrants = dedupeAccessGrants(nextGrants);
|
||||
onChange(accessGrants);
|
||||
};
|
||||
|
||||
const setPublic = (isPublic: boolean) => {
|
||||
const filtered = currentGrants().filter(
|
||||
(grant) =>
|
||||
!(
|
||||
grant.principal_type === 'user' &&
|
||||
grant.principal_id === '*' &&
|
||||
grant.permission === 'read'
|
||||
)
|
||||
);
|
||||
if (isPublic) {
|
||||
filtered.push({
|
||||
principal_type: 'user',
|
||||
principal_id: '*',
|
||||
permission: 'read'
|
||||
});
|
||||
}
|
||||
commitAccessGrants(filtered);
|
||||
};
|
||||
|
||||
const upsertPrincipalGrant = (
|
||||
principalType: 'user' | 'group',
|
||||
principalId: string,
|
||||
permission: 'read' | 'write',
|
||||
grants: AccessGrant[]
|
||||
): AccessGrant[] => {
|
||||
if (
|
||||
grants.some(
|
||||
(grant) =>
|
||||
grant.principal_type === principalType &&
|
||||
grant.principal_id === principalId &&
|
||||
grant.permission === permission
|
||||
)
|
||||
) {
|
||||
return grants;
|
||||
}
|
||||
return [
|
||||
...grants,
|
||||
{
|
||||
principal_type: principalType,
|
||||
principal_id: principalId,
|
||||
permission
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const removePrincipalGrant = (
|
||||
principalType: 'user' | 'group',
|
||||
principalId: string,
|
||||
permission: 'read' | 'write',
|
||||
grants: AccessGrant[]
|
||||
): AccessGrant[] =>
|
||||
grants.filter(
|
||||
(grant) =>
|
||||
!(
|
||||
grant.principal_type === principalType &&
|
||||
grant.principal_id === principalId &&
|
||||
grant.permission === permission
|
||||
)
|
||||
);
|
||||
|
||||
const removePrincipal = (principalType: 'user' | 'group', principalId: string) => {
|
||||
let next = [...currentGrants()];
|
||||
next = removePrincipalGrant(principalType, principalId, 'read', next);
|
||||
next = removePrincipalGrant(principalType, principalId, 'write', next);
|
||||
commitAccessGrants(next);
|
||||
};
|
||||
|
||||
const togglePrincipalWrite = (principalType: 'user' | 'group', principalId: string) => {
|
||||
let next = [...currentGrants()];
|
||||
const hasWrite = hasPrincipalGrant(principalType, principalId, 'write');
|
||||
if (hasWrite) {
|
||||
next = removePrincipalGrant(principalType, principalId, 'write', next);
|
||||
} else {
|
||||
next = upsertPrincipalGrant(principalType, principalId, 'read', next);
|
||||
next = upsertPrincipalGrant(principalType, principalId, 'write', next);
|
||||
}
|
||||
commitAccessGrants(next);
|
||||
};
|
||||
|
||||
const ensureUsersByIds = async (userIds: string[]) => {
|
||||
const pendingIds = userIds.filter((id) => !userById[id] && !resolvingUserIds.has(id));
|
||||
if (!pendingIds.length) return;
|
||||
|
||||
for (const id of pendingIds) {
|
||||
resolvingUserIds.add(id);
|
||||
}
|
||||
|
||||
const fetched = await Promise.all(
|
||||
pendingIds.map(async (id) => {
|
||||
const user = await getUserInfoById(localStorage.token, id).catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
return { id, user };
|
||||
})
|
||||
);
|
||||
|
||||
const nextUserById = { ...userById };
|
||||
for (const item of fetched) {
|
||||
if (item.user?.id) {
|
||||
nextUserById[item.id] = item.user;
|
||||
}
|
||||
resolvingUserIds.delete(item.id);
|
||||
}
|
||||
userById = nextUserById;
|
||||
};
|
||||
|
||||
const handleAddAccess = ({ userIds, groupIds }: { userIds: string[]; groupIds: string[] }) => {
|
||||
let next = [...currentGrants()];
|
||||
|
||||
for (const groupId of groupIds) {
|
||||
next = upsertPrincipalGrant('group', groupId, 'read', next);
|
||||
}
|
||||
for (const userId of userIds) {
|
||||
next = upsertPrincipalGrant('user', userId, 'read', next);
|
||||
}
|
||||
commitAccessGrants(next);
|
||||
};
|
||||
|
||||
// NOTE: We must reference `accessGrants` directly in each reactive
|
||||
// expression so Svelte tracks the dependency.
|
||||
const ensureGroupsByIds = async (groupIds: string[]) => {
|
||||
const pendingIds = groupIds.filter(
|
||||
(id) => !groups.find((g) => g.id === id) && !resolvingGroupIds.has(id)
|
||||
);
|
||||
if (!pendingIds.length) return;
|
||||
|
||||
for (const id of pendingIds) {
|
||||
resolvingGroupIds.add(id);
|
||||
}
|
||||
|
||||
const fetched = await Promise.all(
|
||||
pendingIds.map(async (id) => {
|
||||
const group = await getGroupInfoById(localStorage.token, id).catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
return group;
|
||||
})
|
||||
);
|
||||
|
||||
const newGroups = fetched.filter((g) => g);
|
||||
if (newGroups.length > 0) {
|
||||
groups = [...groups, ...newGroups].filter(
|
||||
(g, index, self) => index === self.findIndex((t) => t.id === g.id)
|
||||
);
|
||||
}
|
||||
|
||||
for (const id of pendingIds) {
|
||||
resolvingGroupIds.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
$: if (readGroupIds.length > 0 || writeGroupIds.length > 0) {
|
||||
void ensureGroupsByIds([...readGroupIds, ...writeGroupIds]);
|
||||
}
|
||||
$: readGroupIds = (accessGrants, getPrincipalIdsByPermission('group', 'read'));
|
||||
$: writeGroupIds = (accessGrants, getPrincipalIdsByPermission('group', 'write'));
|
||||
$: readUserIds =
|
||||
(accessGrants, getPrincipalIdsByPermission('user', 'read').filter((id) => id !== '*'));
|
||||
$: writeUserIds =
|
||||
(accessGrants, getPrincipalIdsByPermission('user', 'write').filter((id) => id !== '*'));
|
||||
|
||||
$: selectedUserIds = Array.from(new Set([...readUserIds, ...writeUserIds]));
|
||||
|
||||
$: selectedUsers = selectedUserIds
|
||||
.map((id) => {
|
||||
return userById[id] ?? { id, name: id, email: '' };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
$: accessGroups = groups
|
||||
.filter((group) => readGroupIds.includes(group.id) || writeGroupIds.includes(group.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
$: if (selectedUserIds.length > 0) {
|
||||
void ensureUsersByIds(selectedUserIds);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (accessControl !== undefined) {
|
||||
const normalizedGrants = normalizeInputToGrants(accessControl);
|
||||
if (stableStringify(normalizedGrants) !== stableStringify(accessGrants)) {
|
||||
accessGrants = normalizedGrants;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
const normalizedGrants = normalizeInputToGrants(accessGrants);
|
||||
if (stableStringify(normalizedGrants) !== stableStringify(accessGrants)) {
|
||||
accessGrants = normalizedGrants;
|
||||
}
|
||||
|
||||
if (accessControl !== undefined) {
|
||||
const nextAccessControl = grantsToLegacyAccessControl(normalizedGrants);
|
||||
if (stableStringify(nextAccessControl) !== stableStringify(accessControl)) {
|
||||
accessControl = nextAccessControl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
groups = await getGroups(localStorage.token, true).catch((error) => {
|
||||
console.log('AccessControl mounted', { accessGrants, accessControl });
|
||||
const res = await getGroups(localStorage.token, true).catch((error) => {
|
||||
console.error(error);
|
||||
return [];
|
||||
});
|
||||
|
||||
if (accessControl === null) {
|
||||
initPublicAccess();
|
||||
} else {
|
||||
accessControl = {
|
||||
read: {
|
||||
group_ids: accessControl?.read?.group_ids ?? [],
|
||||
user_ids: accessControl?.read?.user_ids ?? []
|
||||
},
|
||||
write: {
|
||||
group_ids: accessControl?.write?.group_ids ?? [],
|
||||
user_ids: accessControl?.write?.user_ids ?? []
|
||||
}
|
||||
};
|
||||
}
|
||||
console.log('getGroups res', res);
|
||||
|
||||
groups = [...groups, ...res].filter(
|
||||
(g, index, self) => index === self.findIndex((t) => t.id === g.id)
|
||||
);
|
||||
});
|
||||
|
||||
$: console.log('AccessControl state', {
|
||||
accessGrants,
|
||||
readGroupIds,
|
||||
writeGroupIds,
|
||||
selectedUserIds,
|
||||
groups,
|
||||
accessGroups,
|
||||
selectedUsers
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class=" rounded-lg flex flex-col gap-2">
|
||||
<div class="">
|
||||
<div class=" text-xs font-medium mb-2.5 text-gray-500">{$i18n.t('Visibility')}</div>
|
||||
<AddAccessModal bind:show={showAddAccessModal} onAdd={handleAddAccess} />
|
||||
|
||||
<div class="flex gap-2.5 items-center mb-1">
|
||||
<div class=" rounded-lg flex flex-col gap-1">
|
||||
<div class="py-2">
|
||||
<div class="flex gap-2.5 items-center">
|
||||
<div>
|
||||
<div class=" p-2 bg-black/5 dark:bg-white/5 rounded-full">
|
||||
{#if accessControl !== null}
|
||||
{#if !hasPublicReadGrant(accessGrants ?? [])}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
|
|
@ -106,36 +461,28 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<select
|
||||
id="models"
|
||||
class="dark:bg-gray-900 outline-hidden bg-transparent text-sm font-medium block w-fit pr-10 max-w-full placeholder-gray-400"
|
||||
value={accessControl !== null ? 'private' : 'public'}
|
||||
on:change={(e) => {
|
||||
if (e.target.value === 'public') {
|
||||
accessControl = null;
|
||||
} else {
|
||||
accessControl = {
|
||||
read: {
|
||||
group_ids: [],
|
||||
user_ids: []
|
||||
},
|
||||
write: {
|
||||
group_ids: [],
|
||||
user_ids: []
|
||||
}
|
||||
};
|
||||
}
|
||||
onChange(accessControl);
|
||||
}}
|
||||
<Tooltip
|
||||
content={!(share && sharePublic) && !hasPublicReadGrant(accessGrants ?? [])
|
||||
? $i18n.t('You do not have permission to make this public')
|
||||
: ''}
|
||||
>
|
||||
<option class=" text-gray-700" value="private" selected>{$i18n.t('Private')}</option>
|
||||
{#if share && sharePublic}
|
||||
<option class=" text-gray-700" value="public" selected>{$i18n.t('Public')}</option>
|
||||
{/if}
|
||||
</select>
|
||||
<select
|
||||
id="models"
|
||||
class="dark:bg-gray-900 outline-none bg-transparent text-sm font-medium block w-fit pr-10 max-w-full placeholder-gray-400"
|
||||
value={!hasPublicReadGrant(accessGrants ?? []) ? 'private' : 'public'}
|
||||
on:change={(e) => {
|
||||
setPublic((e.target as HTMLSelectElement).value === 'public');
|
||||
}}
|
||||
>
|
||||
<option class=" text-gray-700" value="private">{$i18n.t('Private')}</option>
|
||||
{#if (share && sharePublic) || hasPublicReadGrant(accessGrants ?? [])}
|
||||
<option class=" text-gray-700" value="public">{$i18n.t('Public')}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</Tooltip>
|
||||
|
||||
<div class=" text-xs text-gray-400 font-medium">
|
||||
{#if accessControl !== null}
|
||||
{#if !hasPublicReadGrant(accessGrants ?? [])}
|
||||
{$i18n.t('Only select users and groups with permission can access')}
|
||||
{:else}
|
||||
{$i18n.t('Accessible to all users')}
|
||||
|
|
@ -146,116 +493,126 @@
|
|||
</div>
|
||||
|
||||
{#if share}
|
||||
{#if accessControl !== null}
|
||||
{@const accessGroups = groups.filter((group) =>
|
||||
(accessControl?.read?.group_ids ?? []).includes(group.id)
|
||||
)}
|
||||
<div class="flex items-center justify-between text-xs font-medium text-gray-500 my-1">
|
||||
<div>
|
||||
<div class="">
|
||||
<div class="flex justify-between mb-2.5">
|
||||
<div class="text-xs font-medium text-gray-500">
|
||||
{$i18n.t('Groups')}
|
||||
{$i18n.t('Access List')}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="px-2 py-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition text-xs font-medium flex items-center gap-1"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showAddAccessModal = true;
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{$i18n.t('Add Access')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Groups -->
|
||||
{#each accessGroups as group}
|
||||
<div class="flex items-center gap-3 justify-between text-sm w-full transition pb-1">
|
||||
<div class="flex items-center gap-2 w-full flex-1">
|
||||
<!-- Placeholder for group icon vs user icon -->
|
||||
<div
|
||||
class="size-5 rounded-full bg-gray-100 dark:bg-gray-850 flex items-center justify-center text-xs"
|
||||
>
|
||||
{group.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div class="truncate text-sm flex items-center gap-2">
|
||||
{group.name}
|
||||
<span class="text-xs text-gray-400 font-normal"
|
||||
>{group?.member_count} {$i18n.t('members')}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if accessGroups.length > 0}
|
||||
<div class="flex flex-col gap-1.5 mb-2 px-0.5 mx-0.5">
|
||||
{#each accessGroups as group}
|
||||
<div class="flex items-center gap-3 justify-between text-sm w-full transition">
|
||||
<div class="flex items-center gap-1.5 w-full">
|
||||
<div>
|
||||
{group.name} <span class="text-xs text-gray-500">{group?.member_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-end items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (accessRoles.includes('write')) {
|
||||
togglePrincipalWrite('group', group.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if writeGroupIds.includes(group.id)}
|
||||
<Badge type={'success'} content={$i18n.t('Write')} />
|
||||
{:else}
|
||||
<Badge type={'info'} content={$i18n.t('Read')} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="w-full flex justify-end items-center gap-0.5">
|
||||
<button
|
||||
class=""
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (accessRoles.includes('write')) {
|
||||
if ((accessControl?.write?.group_ids ?? []).includes(group.id)) {
|
||||
accessControl.write.group_ids = (
|
||||
accessControl?.write?.group_ids ?? []
|
||||
).filter((group_id) => group_id !== group.id);
|
||||
} else {
|
||||
accessControl.write.group_ids = [
|
||||
...(accessControl?.write?.group_ids ?? []),
|
||||
group.id
|
||||
];
|
||||
}
|
||||
onChange(accessControl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if (accessControl?.write?.group_ids ?? []).includes(group.id)}
|
||||
<Badge type={'success'} content={$i18n.t('Write')} />
|
||||
{:else}
|
||||
<Badge type={'info'} content={$i18n.t('Read')} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
accessControl.read.group_ids = (
|
||||
accessControl?.read?.group_ids ?? []
|
||||
).filter((id) => id !== group.id);
|
||||
accessControl.write.group_ids = (
|
||||
accessControl?.write?.group_ids ?? []
|
||||
).filter((id) => id !== group.id);
|
||||
onChange(accessControl);
|
||||
}}
|
||||
>
|
||||
<XMark />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- <div class="flex items-center justify-center">
|
||||
<div class="text-gray-500 text-xs text-center py-2 px-10">
|
||||
{$i18n.t('No groups with access, add a group to grant access')}
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="mb-1">
|
||||
<div class="flex w-full">
|
||||
<div class="flex flex-1 items-center">
|
||||
<div class="w-full px-0.5">
|
||||
<select
|
||||
class=" outline-hidden bg-transparent text-sm block w-full pr-10 max-w-full
|
||||
{selectedGroupId ? '' : 'text-gray-500'}
|
||||
dark:placeholder-gray-500"
|
||||
bind:value={selectedGroupId}
|
||||
on:change={() => {
|
||||
if (selectedGroupId !== '') {
|
||||
accessControl.read.group_ids = [
|
||||
...(accessControl?.read?.group_ids ?? []),
|
||||
selectedGroupId
|
||||
];
|
||||
|
||||
selectedGroupId = '';
|
||||
onChange(accessControl);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option class=" text-gray-700" value="" disabled selected
|
||||
>{$i18n.t('Select a group')}</option
|
||||
>
|
||||
{#each groups.filter((group) => !(accessControl?.read?.group_ids ?? []).includes(group.id)) as group}
|
||||
<option class=" text-gray-700" value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
removePrincipal('group', group.id);
|
||||
}}
|
||||
>
|
||||
<XMark className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Users -->
|
||||
{#each selectedUsers as user}
|
||||
<div
|
||||
class="flex items-center gap-3 justify-between text-sm w-full transition border-b border-gray-50 dark:border-gray-850 pb-2 last:border-0"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full flex-1">
|
||||
<img
|
||||
class="rounded-full size-5 object-cover"
|
||||
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
|
||||
alt={user.name ?? user.id}
|
||||
/>
|
||||
<div class="w-full">
|
||||
<Tooltip content={user.email} placement="top-start">
|
||||
<div class="truncate text-sm">{user.name ?? user.id}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-end items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (accessRoles.includes('write')) {
|
||||
togglePrincipalWrite('user', user.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if writeUserIds.includes(user.id)}
|
||||
<Badge type={'success'} content={$i18n.t('Write')} />
|
||||
{:else}
|
||||
<Badge type={'info'} content={$i18n.t('Read')} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
removePrincipal('user', user.id);
|
||||
}}
|
||||
>
|
||||
<XMark className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !hasPublicReadGrant(accessGrants ?? []) && accessGroups.length === 0 && selectedUsers.length === 0}
|
||||
<div class="text-xs text-gray-500 text-center py-4">
|
||||
{$i18n.t('No access grants. Private to you.')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -6,8 +6,16 @@
|
|||
import AccessControl from './AccessControl.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
type AccessGrant = {
|
||||
id?: string;
|
||||
principal_type: 'user' | 'group';
|
||||
principal_id: string;
|
||||
permission: 'read' | 'write';
|
||||
};
|
||||
|
||||
export let show = false;
|
||||
export let accessControl = {};
|
||||
export let accessGrants: AccessGrant[] = [];
|
||||
export let accessControl: any = undefined;
|
||||
export let accessRoles = ['read'];
|
||||
|
||||
export let share = true;
|
||||
|
|
@ -33,7 +41,14 @@
|
|||
</div>
|
||||
|
||||
<div class="w-full px-5 pb-4 dark:text-white">
|
||||
<AccessControl bind:accessControl {onChange} {accessRoles} {share} {sharePublic} />
|
||||
<AccessControl
|
||||
bind:accessGrants
|
||||
bind:accessControl
|
||||
{onChange}
|
||||
{accessRoles}
|
||||
{share}
|
||||
{sharePublic}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
|||
69
src/lib/components/workspace/common/AddAccessModal.svelte
Normal file
69
src/lib/components/workspace/common/AddAccessModal.svelte
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import MemberSelector from '$lib/components/workspace/common/MemberSelector.svelte';
|
||||
|
||||
export let show = false;
|
||||
export let onAdd = (payload: { userIds: string[]; groupIds: string[] }) => {};
|
||||
|
||||
let userIds: string[] = [];
|
||||
let groupIds: string[] = [];
|
||||
let loading = false;
|
||||
|
||||
const submitHandler = () => {
|
||||
loading = true;
|
||||
onAdd({ userIds, groupIds });
|
||||
show = false;
|
||||
|
||||
userIds = [];
|
||||
groupIds = [];
|
||||
loading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
|
||||
<div class="self-center text-base">
|
||||
<div class="flex items-center gap-0.5 shrink-0">
|
||||
{$i18n.t('Add Access')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<XMark className={'size-5'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-3 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="flex flex-col w-full h-full pb-2">
|
||||
<MemberSelector bind:userIds bind:groupIds includeGroups={true} />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-950 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Add')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
const i18n = getContext('i18n');
|
||||
|
||||
import { user as _user } from '$lib/stores';
|
||||
import { getUserById, searchUsers } from '$lib/apis/users';
|
||||
import { getUserInfoById, searchUsers } from '$lib/apis/users';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
|
||||
if (userIds.length > 0) {
|
||||
userIds.forEach(async (id) => {
|
||||
const res = await getUserById(localStorage.token, id).catch((error) => {
|
||||
const res = await getUserInfoById(localStorage.token, id).catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -131,6 +131,10 @@
|
|||
"code": "lt-LT",
|
||||
"title": "Lithuanian (Lietuvių)"
|
||||
},
|
||||
{
|
||||
"code": "lv-LV",
|
||||
"title": "Latvian (Latviešu)"
|
||||
},
|
||||
{
|
||||
"code": "ms-MY",
|
||||
"title": "Malay (Bahasa Malaysia)"
|
||||
|
|
|
|||
1913
src/lib/i18n/locales/lv-LV/translation.json
Normal file
1913
src/lib/i18n/locales/lv-LV/translation.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,7 @@ export const mobile = writable(false);
|
|||
|
||||
export const socket: Writable<null | Socket> = writable(null);
|
||||
export const activeUserIds: Writable<null | string[]> = writable(null);
|
||||
export const activeChatIds: Writable<Set<string>> = writable(new Set());
|
||||
export const USAGE_POOL: Writable<null | string[]> = writable(null);
|
||||
|
||||
export const theme = writable('system');
|
||||
|
|
|
|||
|
|
@ -1277,17 +1277,20 @@ export const convertOpenApiToToolPayload = (openApiSpec) => {
|
|||
// Extract path and query parameters
|
||||
if (operation.parameters) {
|
||||
operation.parameters.forEach((param) => {
|
||||
let description = param.schema.description || param.description || '';
|
||||
if (param.schema.enum && Array.isArray(param.schema.enum)) {
|
||||
description += `. Possible values: ${param.schema.enum.join(', ')}`;
|
||||
const paramName = param?.name;
|
||||
if (!paramName) return;
|
||||
const paramSchema = param?.schema ?? {};
|
||||
let description = paramSchema.description || param.description || '';
|
||||
if (paramSchema.enum && Array.isArray(paramSchema.enum)) {
|
||||
description += `. Possible values: ${paramSchema.enum.join(', ')}`;
|
||||
}
|
||||
tool.parameters.properties[param.name] = {
|
||||
type: param.schema.type,
|
||||
tool.parameters.properties[paramName] = {
|
||||
type: paramSchema.type,
|
||||
description: description
|
||||
};
|
||||
|
||||
if (param.required) {
|
||||
tool.parameters.required.push(param.name);
|
||||
tool.parameters.required.push(paramName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
content: updatedPrompt.content,
|
||||
version_id: updatedPrompt.version_id,
|
||||
tags: updatedPrompt.tags,
|
||||
access_control: updatedPrompt?.access_control === undefined ? {} : updatedPrompt?.access_control
|
||||
access_grants: updatedPrompt?.access_grants === undefined ? [] : updatedPrompt?.access_grants
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
content: _prompt.content,
|
||||
version_id: _prompt.version_id,
|
||||
tags: _prompt.tags,
|
||||
access_control: _prompt?.access_control === undefined ? {} : _prompt?.access_control
|
||||
access_grants: _prompt?.access_grants === undefined ? [] : _prompt?.access_grants
|
||||
};
|
||||
} else {
|
||||
goto('/workspace/prompts');
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@
|
|||
import PromptEditor from '$lib/components/workspace/Prompts/PromptEditor.svelte';
|
||||
|
||||
let prompt: {
|
||||
title: string;
|
||||
name: string;
|
||||
command: string;
|
||||
content: string;
|
||||
access_control: any | null;
|
||||
tags: string[];
|
||||
access_grants: any[];
|
||||
} | null = null;
|
||||
|
||||
let clone = false;
|
||||
|
|
@ -50,7 +51,7 @@
|
|||
command: _prompt.command,
|
||||
content: _prompt.content,
|
||||
tags: _prompt.tags || [],
|
||||
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
|
||||
access_grants: _prompt.access_grants !== undefined ? _prompt.access_grants : []
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -70,7 +71,7 @@
|
|||
command: _prompt.command,
|
||||
content: _prompt.content,
|
||||
tags: _prompt.tags || [],
|
||||
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
|
||||
access_grants: _prompt.access_grants !== undefined ? _prompt.access_grants : []
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
name: data.name,
|
||||
meta: data.meta,
|
||||
content: data.content,
|
||||
access_control: data.access_control
|
||||
access_grants: data.access_grants
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
|
|
@ -87,7 +87,7 @@
|
|||
name={tool?.name ?? ''}
|
||||
meta={tool?.meta ?? { description: '' }}
|
||||
content={tool?.content ?? ''}
|
||||
accessControl={tool?.access_control !== undefined ? tool.access_control : {}}
|
||||
accessGrants={tool?.access_grants !== undefined ? tool.access_grants : []}
|
||||
{clone}
|
||||
onSave={(value) => {
|
||||
saveHandler(value);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
name: data.name,
|
||||
meta: data.meta,
|
||||
content: data.content,
|
||||
access_control: data.access_control
|
||||
access_grants: data.access_grants
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
name={tool.name}
|
||||
meta={tool.meta}
|
||||
content={tool.content}
|
||||
accessControl={tool.access_control}
|
||||
accessGrants={tool.access_grants ?? []}
|
||||
onSave={(value) => {
|
||||
saveHandler(value);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
import Messages from '$lib/components/chat/Messages.svelte';
|
||||
|
||||
import { getUserById, getUserSettings } from '$lib/apis/users';
|
||||
import { getUserInfoById, getUserSettings } from '$lib/apis/users';
|
||||
import { getModels } from '$lib/apis';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
});
|
||||
|
||||
if (chat) {
|
||||
user = await getUserById(localStorage.token, chat.user_id).catch((error) => {
|
||||
user = await getUserInfoById(localStorage.token, chat.user_id).catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue