mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-24 05:44:07 +00:00
Merge remote-tracking branch 'upstream/dev' into HEAD
# Conflicts: # backend/open_webui/utils/anthropic.py
This commit is contained in:
commit
3f567a21f2
56 changed files with 2876 additions and 1423 deletions
|
|
@ -93,11 +93,9 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
|
|||
|
||||
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
|
||||
|
||||
- ⚡ **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat.
|
||||
- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
|
||||
|
||||
- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
|
||||
- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.
|
||||
|
||||
|
|
|
|||
|
|
@ -1193,6 +1193,20 @@ async def publish_model_provider_request_failed(
|
|||
else 'upstream_error'
|
||||
)
|
||||
|
||||
# Server-log only; the upstream error body is otherwise invisible to admins
|
||||
# (event sinks require an event function or webhook to be configured).
|
||||
log.log(
|
||||
logging.ERROR if status >= 500 else logging.WARNING,
|
||||
'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s',
|
||||
provider,
|
||||
status,
|
||||
error_type,
|
||||
base_url,
|
||||
requested_model or '-',
|
||||
error_code or '-',
|
||||
error_text[:MAX_STRING_LENGTH] or '-',
|
||||
)
|
||||
|
||||
data = {
|
||||
'error_type': error_type,
|
||||
'status': status,
|
||||
|
|
|
|||
|
|
@ -147,7 +147,10 @@ async def get_function_models(request):
|
|||
return pipe_models
|
||||
|
||||
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
|
||||
if models is None:
|
||||
models = {}
|
||||
|
||||
async def execute_pipe(pipe, params):
|
||||
if inspect.iscoroutinefunction(pipe):
|
||||
return await pipe(**params)
|
||||
|
|
|
|||
|
|
@ -1795,8 +1795,23 @@ async def generate_messages(
|
|||
# Counting must not turn a compatible generation request into an outage.
|
||||
log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True)
|
||||
|
||||
model_id = requested_model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
if model_info and model_info.base_model_id:
|
||||
model_id = model_info.base_model_id
|
||||
|
||||
passthrough_params = []
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await openai.get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
model = models.get(model_id)
|
||||
if model:
|
||||
_, _, api_config = await openai.get_openai_connection(model['urlIdx'])
|
||||
passthrough_params = api_config.get('passthrough_params') or []
|
||||
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data)
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
|
||||
|
||||
# Route through the existing chat_completion handler
|
||||
response = await chat_completion(request, openai_payload, user)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
"""add current_message_id to chat
|
||||
|
||||
Revision ID: 9a1b2c3d4e5f
|
||||
Revises: 856c5b02fb54
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '9a1b2c3d4e5f'
|
||||
down_revision: Union[str, None] = '856c5b02fb54'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
BATCH_SIZE = 150
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
if 'current_message_id' not in columns:
|
||||
op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True))
|
||||
|
||||
chat = sa.table(
|
||||
'chat',
|
||||
sa.column('id', sa.String()),
|
||||
sa.column('chat', sa.Text()),
|
||||
sa.column('current_message_id', sa.Text()),
|
||||
)
|
||||
chat_message = sa.table(
|
||||
'chat_message',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('chat_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
has_chat_message = 'chat_message' in inspector.get_table_names()
|
||||
result = conn.execute(
|
||||
sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options(
|
||||
yield_per=BATCH_SIZE,
|
||||
stream_results=True,
|
||||
)
|
||||
)
|
||||
|
||||
while True:
|
||||
rows = result.fetchmany(BATCH_SIZE)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
batch_chat_ids: list[str] = []
|
||||
candidates_by_chat: dict[str, list[str]] = {}
|
||||
current_by_chat: dict[str, str | None] = {}
|
||||
json_messages_by_chat: dict[str, dict[str, dict]] = {}
|
||||
|
||||
for row in rows:
|
||||
values = row._mapping
|
||||
chat_id = values['id']
|
||||
prefix = f'{chat_id}-'
|
||||
batch_chat_ids.append(chat_id)
|
||||
current_by_chat[chat_id] = values['current_message_id']
|
||||
|
||||
chat_data = {}
|
||||
if isinstance(values['chat'], dict):
|
||||
chat_data = values['chat']
|
||||
elif isinstance(values['chat'], str):
|
||||
try:
|
||||
parsed = json.loads(values['chat'])
|
||||
chat_data = parsed if isinstance(parsed, dict) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {}
|
||||
candidates_by_chat[chat_id] = []
|
||||
for candidate in (
|
||||
values['current_message_id'],
|
||||
history.get('currentId'),
|
||||
chat_data.get('currentId'),
|
||||
chat_data.get('branchPointMessageId'),
|
||||
):
|
||||
if not isinstance(candidate, str) or not candidate:
|
||||
continue
|
||||
candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate
|
||||
if candidate not in candidates_by_chat[chat_id]:
|
||||
candidates_by_chat[chat_id].append(candidate)
|
||||
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(chat_data.get('messages'), list):
|
||||
messages = {
|
||||
message['id']: message
|
||||
for message in chat_data['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
if messages:
|
||||
json_messages_by_chat[chat_id] = {
|
||||
message_id: {
|
||||
'parent_id': message.get('parentId') if isinstance(message, dict) else None,
|
||||
'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0,
|
||||
}
|
||||
for message_id, message in messages.items()
|
||||
}
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
|
||||
if has_chat_message:
|
||||
candidate_ids = {
|
||||
f'{chat_id}-{candidate}'
|
||||
for chat_id, candidates in candidates_by_chat.items()
|
||||
for candidate in candidates
|
||||
}
|
||||
if candidate_ids:
|
||||
valid_by_chat: dict[str, set[str]] = {}
|
||||
for row in conn.execute(
|
||||
sa.select(chat_message.c.chat_id, chat_message.c.id).where(
|
||||
chat_message.c.chat_id.in_(batch_chat_ids),
|
||||
chat_message.c.id.in_(candidate_ids),
|
||||
)
|
||||
):
|
||||
values = row._mapping
|
||||
chat_id = values['chat_id']
|
||||
prefix = f'{chat_id}-'
|
||||
message_id = values['id']
|
||||
if message_id and message_id.startswith(prefix):
|
||||
message_id = message_id[len(prefix) :]
|
||||
if message_id:
|
||||
valid_by_chat.setdefault(chat_id, set()).add(message_id)
|
||||
for chat_id, candidates in candidates_by_chat.items():
|
||||
valid_ids = valid_by_chat.get(chat_id, set())
|
||||
for candidate in candidates:
|
||||
if candidate in valid_ids:
|
||||
resolved[chat_id] = candidate
|
||||
break
|
||||
|
||||
unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved]
|
||||
messages_by_chat: dict[str, dict[str, dict]] = {}
|
||||
if unresolved_chat_ids:
|
||||
for row in conn.execute(
|
||||
sa.select(
|
||||
chat_message.c.chat_id,
|
||||
chat_message.c.id,
|
||||
chat_message.c.parent_id,
|
||||
chat_message.c.created_at,
|
||||
).where(chat_message.c.chat_id.in_(unresolved_chat_ids))
|
||||
):
|
||||
values = row._mapping
|
||||
chat_id = values['chat_id']
|
||||
prefix = f'{chat_id}-'
|
||||
message_id = values['id']
|
||||
if message_id and message_id.startswith(prefix):
|
||||
message_id = message_id[len(prefix) :]
|
||||
if not message_id:
|
||||
continue
|
||||
parent_id = values['parent_id']
|
||||
if parent_id and parent_id.startswith(prefix):
|
||||
parent_id = parent_id[len(prefix) :]
|
||||
messages_by_chat.setdefault(chat_id, {})[message_id] = {
|
||||
'parent_id': parent_id,
|
||||
'created_at': values['created_at'] or 0,
|
||||
}
|
||||
|
||||
for chat_id, messages in messages_by_chat.items():
|
||||
parent_ids = {
|
||||
message['parent_id']
|
||||
for message in messages.values()
|
||||
if message.get('parent_id') in messages
|
||||
}
|
||||
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
|
||||
resolved[chat_id] = max(
|
||||
leaf_ids or list(messages),
|
||||
key=lambda message_id: messages[message_id].get('created_at') or 0,
|
||||
)
|
||||
|
||||
for chat_id in batch_chat_ids:
|
||||
if chat_id in resolved:
|
||||
continue
|
||||
|
||||
messages = json_messages_by_chat.get(chat_id, {})
|
||||
valid_candidate = next(
|
||||
(candidate for candidate in candidates_by_chat[chat_id] if candidate in messages),
|
||||
None,
|
||||
)
|
||||
if valid_candidate:
|
||||
resolved[chat_id] = valid_candidate
|
||||
elif messages:
|
||||
parent_ids = {
|
||||
message['parent_id']
|
||||
for message in messages.values()
|
||||
if message.get('parent_id') in messages
|
||||
}
|
||||
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
|
||||
resolved[chat_id] = max(
|
||||
leaf_ids or list(messages),
|
||||
key=lambda message_id: messages[message_id].get('created_at') or 0,
|
||||
)
|
||||
|
||||
updates = [
|
||||
{'chat_id': chat_id, 'current_message_id': message_id}
|
||||
for chat_id, message_id in resolved.items()
|
||||
if message_id and message_id != current_by_chat.get(chat_id)
|
||||
]
|
||||
if updates:
|
||||
conn.execute(
|
||||
sa.update(chat)
|
||||
.where(chat.c.id == sa.bindparam('update_chat_id'))
|
||||
.values(current_message_id=sa.bindparam('update_current_message_id')),
|
||||
[
|
||||
{
|
||||
'update_chat_id': row['chat_id'],
|
||||
'update_current_message_id': row['current_message_id'],
|
||||
}
|
||||
for row in updates
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat', 'current_message_id')
|
||||
|
|
@ -62,6 +62,7 @@ class Chat(Base): # database table mapping for chat entity
|
|||
|
||||
tasks = Column(JSON, nullable=True)
|
||||
summary = Column(Text, nullable=True)
|
||||
current_message_id = Column(Text, nullable=True)
|
||||
|
||||
last_read_at = Column(BigInteger, nullable=True)
|
||||
|
||||
|
|
@ -98,6 +99,7 @@ class ChatModel(BaseModel):
|
|||
|
||||
tasks: list | None = None
|
||||
summary: str | None = None
|
||||
current_message_id: str | None = None
|
||||
|
||||
last_read_at: int | None = None
|
||||
|
||||
|
|
@ -145,6 +147,7 @@ class ChatForm(BaseModel):
|
|||
class ChatImportForm(ChatForm):
|
||||
meta: dict | None = {}
|
||||
pinned: bool | None = False
|
||||
current_message_id: str | None = None
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
|
||||
|
|
@ -177,6 +180,7 @@ class ChatResponse(BaseModel):
|
|||
|
||||
tasks: list | None = None
|
||||
summary: str | None = None
|
||||
current_message_id: str | None = None
|
||||
context_usage: dict | None = None
|
||||
|
||||
|
||||
|
|
@ -280,6 +284,21 @@ class ChatTable:
|
|||
"""Recursively remove null bytes from strings in dict/list structures."""
|
||||
return sanitize_data_for_db(obj)
|
||||
|
||||
def get_current_message_id(self, chat: dict | None) -> str | None:
|
||||
chat = chat or {}
|
||||
history = chat.get('history') if isinstance(chat.get('history'), dict) else {}
|
||||
current_id = history.get('currentId') or chat.get('currentId') or chat.get('branchPointMessageId')
|
||||
if current_id:
|
||||
return current_id
|
||||
|
||||
messages = chat.get('messages')
|
||||
if isinstance(messages, list):
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get('id'):
|
||||
return message['id']
|
||||
|
||||
return None
|
||||
|
||||
def _sanitize_chat_row(self, chat_item):
|
||||
"""
|
||||
Clean a Chat SQLAlchemy model's title + chat JSON,
|
||||
|
|
@ -375,6 +394,7 @@ class ChatTable:
|
|||
'chat': self._clean_null_bytes(form_data.chat),
|
||||
'folder_id': form_data.folder_id,
|
||||
'meta': internal_meta or {},
|
||||
'current_message_id': self.get_current_message_id(form_data.chat),
|
||||
'created_at': int(time.time()),
|
||||
'updated_at': int(time.time()),
|
||||
'last_read_at': int(time.time()),
|
||||
|
|
@ -388,8 +408,14 @@ class ChatTable:
|
|||
|
||||
# Dual-write initial messages to chat_message table
|
||||
try:
|
||||
history = form_data.chat.get('history', {})
|
||||
messages = history.get('messages', {})
|
||||
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(form_data.chat.get('messages'), list):
|
||||
messages = {
|
||||
message.get('id'): message
|
||||
for message in form_data.chat['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get('role'):
|
||||
await ChatMessages.upsert_message(
|
||||
|
|
@ -458,6 +484,7 @@ class ChatTable:
|
|||
'meta': form_data.meta,
|
||||
'pinned': form_data.pinned,
|
||||
'folder_id': form_data.folder_id,
|
||||
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
|
||||
'created_at': (form_data.created_at if form_data.created_at else int(time.time())),
|
||||
'updated_at': (form_data.updated_at if form_data.updated_at else int(time.time())),
|
||||
}
|
||||
|
|
@ -497,8 +524,14 @@ class ChatTable:
|
|||
|
||||
# Dual-write messages to chat_message table
|
||||
for form_data, chat_obj in zip(chat_import_forms, chats):
|
||||
history = form_data.chat.get('history', {})
|
||||
messages = history.get('messages', {})
|
||||
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(form_data.chat.get('messages'), list):
|
||||
messages = {
|
||||
message.get('id'): message
|
||||
for message in form_data.chat['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get('role'):
|
||||
try:
|
||||
|
|
@ -530,6 +563,8 @@ class ChatTable:
|
|||
|
||||
chat_item.chat = self._clean_null_bytes(chat)
|
||||
chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat'
|
||||
if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')):
|
||||
chat_item.current_message_id = self.get_current_message_id(chat)
|
||||
|
||||
if touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
|
@ -1627,9 +1662,23 @@ class ChatTable:
|
|||
postgres_content_sql = """
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS message
|
||||
WHERE json_typeof(message->'content') = 'string'
|
||||
AND LOWER(message->>'content') LIKE '%' || :content_key || '%'
|
||||
FROM chat_message AS message
|
||||
WHERE message.chat_id = Chat.id
|
||||
AND message.user_id = Chat.user_id
|
||||
AND json_typeof(message.content) = 'string'
|
||||
AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.chat#>'{history,messages}') AS history_message
|
||||
WHERE json_typeof(history_message.value->'content') = 'string'
|
||||
AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
|
||||
WHERE json_typeof(legacy_message->'content') = 'string'
|
||||
AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
"""
|
||||
|
||||
|
|
@ -1722,17 +1771,21 @@ class ChatTable:
|
|||
folder_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 60,
|
||||
sort_by: str = 'updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[dict]:
|
||||
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
|
||||
async with get_async_db_context(db) as session:
|
||||
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
|
||||
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
|
||||
stmt = (
|
||||
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
|
||||
.filter_by(folder_id=folder_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
.order_by(order_clause, Chat.id)
|
||||
)
|
||||
|
||||
if skip:
|
||||
|
|
@ -1754,6 +1807,22 @@ class ChatTable:
|
|||
for chat in all_chats
|
||||
]
|
||||
|
||||
async def count_all_chats_by_folder_id(
|
||||
self,
|
||||
folder_id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> int:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = (
|
||||
select(func.count(Chat.id))
|
||||
.filter_by(folder_id=folder_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
async def get_chats_by_folder_ids_and_user_id(
|
||||
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
|
||||
) -> list[ChatModel]:
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ API_CONFIG_FIELDS = (
|
|||
'azure',
|
||||
'api_version',
|
||||
'extra_params',
|
||||
'passthrough_params',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from copy import deepcopy
|
||||
from typing import Any, Optional
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
|
@ -22,6 +23,38 @@ log = logging.getLogger(__name__)
|
|||
_warned_profile_urls: set[str] = set()
|
||||
|
||||
|
||||
def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any:
|
||||
"""Drop duplicated extracted text from ModelMeta.knowledge."""
|
||||
if not isinstance(knowledge, list):
|
||||
return knowledge
|
||||
|
||||
sanitized = []
|
||||
|
||||
for item in knowledge:
|
||||
if not isinstance(item, dict):
|
||||
sanitized.append(item)
|
||||
continue
|
||||
|
||||
next_item = item
|
||||
data = item.get('data')
|
||||
if isinstance(data, dict) and 'content' in data:
|
||||
next_item = deepcopy(item)
|
||||
next_item.get('data', {}).pop('content', None)
|
||||
|
||||
file = next_item.get('file')
|
||||
file_data = file.get('data') if isinstance(file, dict) else None
|
||||
if isinstance(file_data, dict) and 'content' in file_data:
|
||||
if next_item is item:
|
||||
next_item = deepcopy(item)
|
||||
file = next_item.get('file')
|
||||
file_data = file.get('data') if isinstance(file, dict) else None
|
||||
file_data.pop('content', None)
|
||||
|
||||
sanitized.append(next_item)
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
# --- Models DB Schema ---
|
||||
|
||||
|
||||
|
|
@ -37,6 +70,7 @@ class ModelMeta(BaseModel):
|
|||
profile_image_url: str | None = None
|
||||
description: str | None = Field(default=None, description='User-facing description of the model.')
|
||||
capabilities: dict | None = None
|
||||
knowledge: list[Any] | None = None
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
|
@ -56,6 +90,11 @@ class ModelMeta(BaseModel):
|
|||
)
|
||||
return None
|
||||
|
||||
@field_validator('knowledge', mode='before')
|
||||
@classmethod
|
||||
def strip_knowledge_content(cls, v):
|
||||
return strip_extracted_content_from_model_knowledge(v)
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def normalize_tags(cls, data):
|
||||
|
|
@ -152,6 +191,14 @@ class ModelsTable:
|
|||
access_grants: list[AccessGrantModel | None] = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ModelModel:
|
||||
if isinstance(model.meta, dict):
|
||||
knowledge = model.meta.get('knowledge')
|
||||
stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge)
|
||||
if stripped_knowledge != knowledge:
|
||||
model.meta = {**model.meta, 'knowledge': stripped_knowledge}
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
|
||||
model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'})
|
||||
model_data['access_grants'] = (
|
||||
access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from open_webui.tasks import has_active_tasks, stop_item_tasks
|
|||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.chat_fork import build_fork_history
|
||||
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.models import get_all_models
|
||||
|
|
@ -1167,7 +1168,8 @@ async def compact_chat_by_id(
|
|||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id)
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId'))
|
||||
current_message_id = chat.current_message_id or history.get('currentId')
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id)
|
||||
model_id = (form_data.model if form_data else None) or next(
|
||||
(message.get('model') for message in reversed(message_list) if message.get('model')),
|
||||
None,
|
||||
|
|
@ -1564,6 +1566,96 @@ class CloneForm(BaseModel):
|
|||
title: str | None = None
|
||||
|
||||
|
||||
class ForkForm(BaseModel):
|
||||
message_id: str | None = None
|
||||
|
||||
|
||||
@router.post('/{id}/fork', response_model=ChatResponse | None)
|
||||
async def fork_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ForkForm | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await require_chat_import_permission(request, user, db)
|
||||
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if await has_active_tasks(request.app.state.redis, id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before forking.',
|
||||
)
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
|
||||
if any(
|
||||
message.get('role') == 'assistant' and message.get('done') is False
|
||||
for message in messages_map.values()
|
||||
if isinstance(message, dict)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before forking.',
|
||||
)
|
||||
|
||||
source_message_id = (form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
|
||||
if not source_message_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork')
|
||||
|
||||
try:
|
||||
fork_history, fork_messages = build_fork_history(messages_map, source_message_id)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST,
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
updated_chat = {**(chat.chat or {})}
|
||||
updated_chat.pop('currentId', None)
|
||||
updated_chat.update(
|
||||
{
|
||||
'originalChatId': chat.id,
|
||||
'branchPointMessageId': source_message_id,
|
||||
'title': f'{chat.title} (fork)',
|
||||
'history': fork_history,
|
||||
'messages': fork_messages,
|
||||
}
|
||||
)
|
||||
meta = {
|
||||
**(chat.meta or {}),
|
||||
'forked_from': chat.id,
|
||||
'forked_from_message_id': source_message_id,
|
||||
}
|
||||
|
||||
fork = await Chats.insert_new_chat(
|
||||
str(uuid4()),
|
||||
user.id,
|
||||
ChatForm(chat=updated_chat, folder_id=chat.folder_id),
|
||||
db=db,
|
||||
internal_meta=meta,
|
||||
)
|
||||
|
||||
if not fork:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if chat.pinned:
|
||||
fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_CLONED,
|
||||
actor=user,
|
||||
subject_id=fork.id,
|
||||
data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
|
||||
)
|
||||
return ChatResponse(**fork.model_dump())
|
||||
|
||||
|
||||
@router.post('/{id}/clone', response_model=ChatResponse | None)
|
||||
async def clone_chat_by_id(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import uuid
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from open_webui.config import UPLOAD_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
|
@ -482,6 +482,9 @@ async def update_folder_access_by_id(
|
|||
async def get_shared_folder_chats(
|
||||
request: Request,
|
||||
id: str,
|
||||
page: int | None = Query(None, ge=1),
|
||||
sort_by: str = Query('updated_at'),
|
||||
sort_dir: str = Query('desc'),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
|
@ -505,7 +508,17 @@ async def get_shared_folder_chats(
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
|
||||
limit = 10
|
||||
skip = (page - 1) * limit if page is not None else 0
|
||||
chats = await Chats.get_all_chats_by_folder_id(
|
||||
id,
|
||||
skip=skip,
|
||||
limit=limit if page is not None else 60,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
db=db,
|
||||
)
|
||||
total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats)
|
||||
|
||||
# Resolve owner names for display (avatar URLs are constructed client-side)
|
||||
owner_cache: dict[str, str] = {}
|
||||
|
|
@ -516,10 +529,13 @@ async def get_shared_folder_chats(
|
|||
owner_cache[uid] = u.name if u else 'Unknown'
|
||||
chat['owner_name'] = owner_cache[uid]
|
||||
|
||||
return {
|
||||
response = {
|
||||
'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats],
|
||||
'folder_permission': 'write' if has_write else 'read',
|
||||
}
|
||||
if page is not None:
|
||||
response.update({'total': total, 'has_more': skip + limit < total})
|
||||
return response
|
||||
|
||||
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -785,7 +785,11 @@ async def image_generations(
|
|||
images.append({'url': url})
|
||||
return images
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
|
||||
if form_data.model:
|
||||
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
|
||||
# persists the global default and switches the shared backend. Only an
|
||||
# admin may do that; a non-admin generates on the currently configured
|
||||
# checkpoint. The model field is not a per-user selection on this backend.
|
||||
if form_data.model and user.role == 'admin':
|
||||
await set_image_model(request, form_data.model)
|
||||
|
||||
data = {
|
||||
|
|
|
|||
|
|
@ -1180,6 +1180,14 @@ class OpenAICompletionForm(BaseModel):
|
|||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
class OpenAIEmbeddingsForm(BaseModel):
|
||||
"""Payload for the OpenAI-compatible /v1/embeddings proxy."""
|
||||
|
||||
model: str
|
||||
input: object
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
@router.post('/v1/completions')
|
||||
@router.post('/v1/completions/{url_idx}')
|
||||
async def generate_openai_completion(
|
||||
|
|
@ -1235,6 +1243,56 @@ async def generate_openai_completion(
|
|||
)
|
||||
|
||||
|
||||
@router.post('/v1/embeddings')
|
||||
@router.post('/v1/embeddings/{url_idx}')
|
||||
async def generate_openai_embeddings(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
url_idx: int | None = None,
|
||||
user=Depends(get_verified_user), # noqa: B008
|
||||
):
|
||||
"""Forward an embeddings request via the OpenAI-compatible proxy."""
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
metadata = form_data.pop('metadata', None)
|
||||
|
||||
try:
|
||||
form_data = OpenAIEmbeddingsForm(**form_data)
|
||||
except Exception as exc:
|
||||
log.exception(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
payload = {**form_data.model_dump(exclude_none=True)}
|
||||
payload.pop('metadata', None)
|
||||
|
||||
model_id = form_data.model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
if model_info is not None:
|
||||
if model_info.base_model_id:
|
||||
payload['model'] = model_info.base_model_id
|
||||
await check_model_access(user, model_info)
|
||||
else:
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/embeddings',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
metadata=metadata,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/v1/chat/completions')
|
||||
@router.post('/v1/chat/completions/{url_idx}')
|
||||
async def generate_openai_chat_completion(
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ class SharingPermissions(BaseModel):
|
|||
public_skills: bool = False
|
||||
notes: bool = False
|
||||
public_notes: bool = True
|
||||
folders: bool = False
|
||||
public_chats: bool = False
|
||||
public_calendars: bool = False
|
||||
|
||||
|
|
@ -475,6 +476,23 @@ async def update_user_settings_by_session_user(
|
|||
# If the user is not an admin and does not have permission to use tool servers, remove the key
|
||||
updated_user_settings['ui'].pop('toolServers', None)
|
||||
|
||||
ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and (
|
||||
'notifications' in updated_user_settings
|
||||
or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
|
||||
)
|
||||
and not await has_permission(
|
||||
user.id,
|
||||
'features.webhooks',
|
||||
await Config.get('user.permissions'),
|
||||
)
|
||||
):
|
||||
updated_user_settings.pop('notifications', None)
|
||||
if isinstance(ui_notifications, dict):
|
||||
ui_notifications.pop('webhook_url', None)
|
||||
|
||||
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
|
||||
if user:
|
||||
await publish_event(
|
||||
|
|
|
|||
|
|
@ -57,6 +57,29 @@ REDIS = None
|
|||
# Configure CORS for Socket.IO
|
||||
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
|
||||
|
||||
class LocalFilteredRedisManager(socketio.AsyncRedisManager):
|
||||
"""AsyncRedisManager that drops pub/sub emits with no local recipients.
|
||||
|
||||
Every instance subscribed to the shared Socket.IO channel receives every
|
||||
emit published by the whole fleet. Upstream ``_handle_emit`` re-encodes
|
||||
the full packet before discovering the target room has no participants on
|
||||
this instance, so each instance burns CPU serializing payloads addressed
|
||||
to sessions it does not host — a cost that grows with instance count.
|
||||
Bail out before that work when the room is empty here. Only string
|
||||
rooms take the fast path; broadcasts (``room=None``) and any other
|
||||
room shape (e.g. lists, which upstream indexes before validating)
|
||||
always pass through unchanged.
|
||||
"""
|
||||
|
||||
async def _handle_emit(self, message):
|
||||
room = message.get('room')
|
||||
if isinstance(room, str):
|
||||
namespace = message.get('namespace') or '/'
|
||||
if next(self.get_participants(namespace, room), None) is None:
|
||||
return
|
||||
await super()._handle_emit(message)
|
||||
|
||||
|
||||
if WEBSOCKET_MANAGER == 'redis':
|
||||
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
|
||||
ws_redis_url = (
|
||||
|
|
@ -64,7 +87,7 @@ if WEBSOCKET_MANAGER == 'redis':
|
|||
if sentinel_hosts
|
||||
else WEBSOCKET_REDIS_URL
|
||||
)
|
||||
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
|
||||
redis_manager = LocalFilteredRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
|
||||
sio = socketio.AsyncServer(
|
||||
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
||||
async_mode='asgi',
|
||||
|
|
|
|||
|
|
@ -686,12 +686,12 @@ async def _kb_grep(
|
|||
|
||||
# Grep on piped input
|
||||
if piped_input is not None:
|
||||
lines = piped_input.split('\\n')
|
||||
lines = piped_input.split('\n')
|
||||
matched = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
matched.append(f'{i}: {line}')
|
||||
return '\\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
return '\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
|
||||
# Single file grep
|
||||
if file_ref and not dir_scope:
|
||||
|
|
@ -702,7 +702,7 @@ async def _kb_grep(
|
|||
elif 'error' in resolved:
|
||||
return resolved['error']
|
||||
else:
|
||||
lines = resolved['content'].split('\\n')
|
||||
lines = resolved['content'].split('\n')
|
||||
matched = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
|
|
@ -715,7 +715,7 @@ async def _kb_grep(
|
|||
|
||||
if not matched:
|
||||
return f'No matches for "{pattern}" in {resolved["filename"]}'
|
||||
return '\\n'.join(matched)
|
||||
return '\n'.join(matched)
|
||||
|
||||
# Cross-file grep (optionally scoped to directory)
|
||||
accessible = await _get_accessible_files(user, model_knowledge)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from open_webui.models.functions import Functions
|
|||
from open_webui.models.users import UserModel
|
||||
from open_webui.socket.main import get_event_call, get_event_emitter
|
||||
from open_webui.utils.middleware import process_tool_result
|
||||
from open_webui.utils.models import get_all_models
|
||||
from open_webui.utils.models import check_model_access, get_all_models
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
|
|
@ -46,6 +46,23 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
|
|||
raise Exception('Model not found')
|
||||
model = models[model_id]
|
||||
|
||||
# Availability gate — keep this route consistent with the actions a model
|
||||
# actually surfaces to the client. Executing admin-authored Function code is
|
||||
# intended; this only stops a disabled, unassigned, or access-restricted
|
||||
# action from being reached by calling the route with a raw action_id.
|
||||
if action.type != 'action' or not action.is_active:
|
||||
raise Exception(f'Action not available: {action_id}')
|
||||
|
||||
# Direct connections carry a client-supplied model the caller already owns,
|
||||
# so scope the model-bound checks to server-resolved models.
|
||||
if not getattr(request.state, 'direct', False) and user.role != 'admin':
|
||||
await check_model_access(user, model)
|
||||
# model['actions'] entries are '<function_id>' or '<function_id>.<sub_id>';
|
||||
# the function id is always the prefix.
|
||||
surfaced_action_ids = {item.get('id', '').split('.', 1)[0] for item in model.get('actions', [])}
|
||||
if action_id not in surfaced_action_ids:
|
||||
raise Exception(f'Action not available: {action_id}')
|
||||
|
||||
__event_emitter__ = await get_event_emitter(
|
||||
{
|
||||
'chat_id': data['chat_id'],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ANTHROPIC_CONVERTED_REQUEST_PARAMS = {
|
||||
'model',
|
||||
'messages',
|
||||
'system',
|
||||
'max_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'top_k',
|
||||
'stop_sequences',
|
||||
'stream',
|
||||
'metadata',
|
||||
'service_tier',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'reasoning_effort',
|
||||
}
|
||||
|
||||
|
||||
def is_anthropic_url(url: str) -> bool:
|
||||
"""Check if the URL is an Anthropic API endpoint."""
|
||||
|
|
@ -154,7 +171,9 @@ def _finalize_openai_content(blocks: list) -> str | list:
|
|||
return blocks
|
||||
|
||||
|
||||
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
def convert_anthropic_to_openai_payload(
|
||||
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
|
||||
|
||||
|
|
@ -395,6 +414,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
|||
if 'max_tokens' in anthropic_payload:
|
||||
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
|
||||
|
||||
captured_passthrough_params = {
|
||||
param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS
|
||||
}
|
||||
if isinstance(passthrough_params, str):
|
||||
passthrough_params = passthrough_params.split(',')
|
||||
elif not isinstance(passthrough_params, (list, tuple, set)):
|
||||
passthrough_params = []
|
||||
passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()}
|
||||
if '*' in passthrough_param_names:
|
||||
openai_payload.update(captured_passthrough_params)
|
||||
else:
|
||||
for param in passthrough_param_names:
|
||||
if param in captured_passthrough_params:
|
||||
openai_payload[param] = captured_passthrough_params[param]
|
||||
|
||||
output_config = anthropic_payload.get('output_config')
|
||||
if isinstance(output_config, dict):
|
||||
if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload:
|
||||
openai_payload['reasoning_effort'] = output_config['effort']
|
||||
|
||||
format_config = output_config.get('format')
|
||||
if isinstance(format_config, dict):
|
||||
format_type = format_config.get('type')
|
||||
if format_type == 'json_schema':
|
||||
json_schema = {
|
||||
'name': format_config.get('name', 'response_schema'),
|
||||
'schema': format_config.get('schema', {}),
|
||||
}
|
||||
if 'description' in format_config:
|
||||
json_schema['description'] = format_config['description']
|
||||
if 'strict' in format_config:
|
||||
json_schema['strict'] = format_config['strict']
|
||||
openai_payload['response_format'] = {
|
||||
'type': 'json_schema',
|
||||
'json_schema': json_schema,
|
||||
}
|
||||
elif format_type == 'json_object':
|
||||
openai_payload['response_format'] = {'type': format_type}
|
||||
|
||||
if 'reasoning_effort' in anthropic_payload:
|
||||
openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort']
|
||||
|
||||
# Common parameters
|
||||
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
|
||||
if param in anthropic_payload:
|
||||
|
|
@ -471,6 +532,32 @@ def convert_openai_to_anthropic_response(
|
|||
|
||||
# Build content blocks
|
||||
content = []
|
||||
message_thinking = message.get('thinking')
|
||||
thinking_blocks = message.get('thinking_blocks') or []
|
||||
if not thinking_blocks and isinstance(message_thinking, dict):
|
||||
thinking_blocks = message_thinking.get('blocks') or []
|
||||
|
||||
has_thinking = False
|
||||
for block in thinking_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
thinking = block.get('thinking') or block.get('content') or block.get('text')
|
||||
if not thinking:
|
||||
continue
|
||||
|
||||
thinking_block = {'type': 'thinking', 'thinking': thinking}
|
||||
if block.get('signature'):
|
||||
thinking_block['signature'] = block['signature']
|
||||
content.append(thinking_block)
|
||||
has_thinking = True
|
||||
|
||||
reasoning_content = message.get('reasoning_content') or message.get('reasoning')
|
||||
if not reasoning_content and isinstance(message_thinking, str):
|
||||
reasoning_content = message_thinking
|
||||
if reasoning_content and not has_thinking:
|
||||
content.append({'type': 'thinking', 'thinking': reasoning_content})
|
||||
|
||||
message_content = message.get('content')
|
||||
if message_content:
|
||||
content.append({'type': 'text', 'text': message_content})
|
||||
|
|
@ -560,6 +647,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
# Track content blocks with a running index.
|
||||
# Each text block or tool_use block gets its own index.
|
||||
current_block_index = 0
|
||||
thinking_block_open = False
|
||||
text_block_open = False
|
||||
|
||||
# Accumulated state for each tool call, keyed by tool call id.
|
||||
|
|
@ -623,11 +711,52 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
# Update usage if present
|
||||
capture_usage(data.get('usage'))
|
||||
|
||||
reasoning_content = (
|
||||
delta.get('reasoning_content')
|
||||
or delta.get('reasoning')
|
||||
or delta.get('thinking')
|
||||
or message.get('reasoning_content')
|
||||
or message.get('reasoning')
|
||||
)
|
||||
if not reasoning_content:
|
||||
thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or []
|
||||
for block in thinking_blocks:
|
||||
if isinstance(block, dict):
|
||||
reasoning_content = block.get('thinking') or block.get('content') or block.get('text')
|
||||
if reasoning_content:
|
||||
break
|
||||
|
||||
if reasoning_content and not text_block_open and not has_tool_calls:
|
||||
if not thinking_block_open:
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
'index': current_block_index,
|
||||
'content_block': {'type': 'thinking', 'thinking': ''},
|
||||
}
|
||||
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
|
||||
thinking_block_open = True
|
||||
|
||||
block_delta = {
|
||||
'type': 'content_block_delta',
|
||||
'index': current_block_index,
|
||||
'delta': {'type': 'thinking_delta', 'thinking': reasoning_content},
|
||||
}
|
||||
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
|
||||
|
||||
# --- Handle text content ---
|
||||
# Anthropic expects text blocks before tool blocks, so skip
|
||||
# text deltas once any tool call has started.
|
||||
content = delta.get('content')
|
||||
if content and not has_tool_calls:
|
||||
if thinking_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
'index': current_block_index,
|
||||
}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
thinking_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
if not text_block_open:
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
|
|
@ -653,6 +782,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
|
||||
if tool_calls:
|
||||
# Close text block if one is open (text comes before tools)
|
||||
if thinking_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
'index': current_block_index,
|
||||
}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
thinking_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
if text_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
|
|
@ -764,6 +902,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
except Exception as e:
|
||||
log.error(f'Error in Anthropic stream conversion: {e}')
|
||||
|
||||
# Close any open thinking block
|
||||
if thinking_block_open:
|
||||
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
current_block_index += 1
|
||||
|
||||
# Flush any tools that buffered arguments but never emitted a block
|
||||
for tool in tracked_tool_calls.values():
|
||||
if not tool['started'] and tool['name']:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
|||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from open_webui.env import CUSTOM_API_KEY_HEADER
|
||||
from open_webui.internal.db import ScopedSession
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.auth import get_http_authorization_cred
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request
|
||||
|
|
@ -138,9 +137,7 @@ class AuthTokenMiddleware:
|
|||
the middleware checks that instead and avoids the 401 short-circuit.
|
||||
|
||||
Routes that depend on `get_verified_user` etc. read this state.
|
||||
Also exposes `request.state.enable_api_keys` (snapshotted at request
|
||||
entry from runtime config) and stamps an `X-Process-Time` response
|
||||
header.
|
||||
Also stamps an `X-Process-Time` response header.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, fastapi_app) -> None:
|
||||
|
|
@ -166,7 +163,6 @@ class AuthTokenMiddleware:
|
|||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
|
||||
|
||||
request.state.token = token
|
||||
request.state.enable_api_keys = await Config.get('auth.enable_api_keys')
|
||||
|
||||
async def send_with_timing(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
|
|
@ -231,7 +227,15 @@ class RedirectMiddleware:
|
|||
return
|
||||
|
||||
path = scope.get('path', '')
|
||||
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
|
||||
raw_query = scope.get('query_string', b'')
|
||||
# This middleware only acts on /watch?v= and ?shared= URLs; skip the
|
||||
# decode + parse_qs work for every other GET. (A false positive on the
|
||||
# substring check just falls through to the full parse below.)
|
||||
if not (path.endswith('/watch') or b'shared' in raw_query):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
query_string = raw_query.decode('latin-1', errors='replace')
|
||||
query_params = parse_qs(query_string)
|
||||
|
||||
redirect_params: dict[str, str] = {}
|
||||
|
|
|
|||
|
|
@ -437,24 +437,24 @@ async def get_current_user_by_api_key(request, api_key: str):
|
|||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
user_permissions = await Config.get('user.permissions')
|
||||
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
|
||||
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
|
||||
if not await Config.get('auth.enable_api_keys'):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
|
||||
if not request.state.enable_api_keys or (
|
||||
user.role != 'admin'
|
||||
and not await has_permission(
|
||||
if user.role != 'admin':
|
||||
user_permissions = await Config.get('user.permissions')
|
||||
if not await has_permission(
|
||||
user.id,
|
||||
'features.api_keys',
|
||||
user_permissions,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
|
||||
# Enforce endpoint restrictions — checked here (not in middleware)
|
||||
# so it applies regardless of how the API key was transported
|
||||
# (Authorization header, cookie, x-api-key header, etc.).
|
||||
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
|
||||
if enable_endpoint_restrictions:
|
||||
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
|
||||
allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()]
|
||||
request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
|
||||
is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths)
|
||||
|
|
|
|||
41
backend/open_webui/utils/chat_fork.py
Normal file
41
backend/open_webui/utils/chat_fork.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from copy import deepcopy
|
||||
|
||||
|
||||
def build_fork_history(messages_map: dict, source_message_id: str) -> tuple[dict, list[dict]]:
|
||||
if not messages_map:
|
||||
raise ValueError('chat has no messages to fork')
|
||||
|
||||
branch: list[tuple[str, dict]] = []
|
||||
seen: set[str] = set()
|
||||
message_id = source_message_id
|
||||
|
||||
while message_id:
|
||||
if message_id in seen:
|
||||
raise ValueError('message branch contains a cycle')
|
||||
seen.add(message_id)
|
||||
|
||||
message = messages_map.get(message_id)
|
||||
if not isinstance(message, dict):
|
||||
raise ValueError('message not found')
|
||||
|
||||
branch.append((message_id, message))
|
||||
message_id = message.get('parentId')
|
||||
|
||||
fork_messages: dict[str, dict] = {}
|
||||
ordered_messages: list[dict] = []
|
||||
parent_id = None
|
||||
|
||||
for message_id, message in reversed(branch):
|
||||
copied = deepcopy(message)
|
||||
copied['id'] = message_id
|
||||
copied['parentId'] = parent_id
|
||||
copied['childrenIds'] = []
|
||||
|
||||
if parent_id:
|
||||
fork_messages[parent_id]['childrenIds'] = [message_id]
|
||||
|
||||
fork_messages[message_id] = copied
|
||||
ordered_messages.append(copied)
|
||||
parent_id = message_id
|
||||
|
||||
return {'messages': fork_messages, 'currentId': source_message_id}, ordered_messages
|
||||
|
|
@ -148,8 +148,13 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
|
|||
if not config['enable']:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'disabled'}
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
current_id = history.get('currentId')
|
||||
chat_data = chat.chat or {}
|
||||
history = chat_data.get('history') or {}
|
||||
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
|
||||
if not current_id:
|
||||
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
|
||||
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
|
||||
current_id = chat_data['messages'][-1].get('id')
|
||||
if not current_id:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'empty'}
|
||||
|
||||
|
|
@ -216,8 +221,13 @@ def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: d
|
|||
|
||||
|
||||
async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None:
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
current_id = history.get('currentId')
|
||||
chat_data = chat.chat or {}
|
||||
history = chat_data.get('history') or {}
|
||||
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
|
||||
if not current_id:
|
||||
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
|
||||
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
|
||||
current_id = chat_data['messages'][-1].get('id')
|
||||
if not current_id:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -4490,7 +4490,12 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
user,
|
||||
)
|
||||
|
||||
content = f'{content}{value}'
|
||||
if isinstance(value, str):
|
||||
# In-place append — avoids copying the full
|
||||
# accumulated response on every chunk.
|
||||
content += value
|
||||
else:
|
||||
content = f'{content}{value}'
|
||||
|
||||
# Check if we're inside a tag-based block
|
||||
# (reasoning, code_interpreter, or solution).
|
||||
|
|
@ -4597,16 +4602,17 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith(
|
||||
'channel:'
|
||||
):
|
||||
current_output = full_output()
|
||||
# Save message in the database
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'output': full_output(),
|
||||
'output': current_output,
|
||||
},
|
||||
)
|
||||
data = {
|
||||
'output': full_output(),
|
||||
'output': current_output,
|
||||
}
|
||||
delta_type = 'content'
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
'models.base_models_cache',
|
||||
'evaluation.arena.enable',
|
||||
'evaluation.arena.models',
|
||||
'models.default_metadata',
|
||||
)
|
||||
if (
|
||||
request.app.state.MODELS
|
||||
|
|
@ -125,23 +126,21 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
]
|
||||
models = models + arena_models
|
||||
|
||||
global_action_ids = (
|
||||
{function.id for function in await Functions.get_global_action_functions()} if ENABLE_PLUGINS else set()
|
||||
)
|
||||
enabled_action_ids = (
|
||||
{function.id for function in await Functions.get_functions_by_type('action', active_only=True)}
|
||||
if ENABLE_PLUGINS
|
||||
else set()
|
||||
)
|
||||
# One query per type: the global sets are subsets of the active sets, so
|
||||
# deriving them from the same rows halves the function-table queries.
|
||||
if ENABLE_PLUGINS:
|
||||
active_actions = await Functions.get_functions_by_type('action', active_only=True)
|
||||
global_action_ids = {function.id for function in active_actions if function.is_global}
|
||||
enabled_action_ids = {function.id for function in active_actions}
|
||||
|
||||
global_filter_ids = (
|
||||
{function.id for function in await Functions.get_global_filter_functions()} if ENABLE_PLUGINS else set()
|
||||
)
|
||||
enabled_filter_ids = (
|
||||
{function.id for function in await Functions.get_functions_by_type('filter', active_only=True)}
|
||||
if ENABLE_PLUGINS
|
||||
else set()
|
||||
)
|
||||
active_filters = await Functions.get_functions_by_type('filter', active_only=True)
|
||||
global_filter_ids = {function.id for function in active_filters if function.is_global}
|
||||
enabled_filter_ids = {function.id for function in active_filters}
|
||||
else:
|
||||
global_action_ids = set()
|
||||
enabled_action_ids = set()
|
||||
global_filter_ids = set()
|
||||
enabled_filter_ids = set()
|
||||
|
||||
custom_models = await Models.get_all_models()
|
||||
|
||||
|
|
@ -305,7 +304,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
|
||||
# Apply global model defaults to all models
|
||||
# Per-model overrides take precedence over global defaults
|
||||
default_metadata = await Config.get('models.default_metadata', {}) or {}
|
||||
default_metadata = config.get('models.default_metadata') or {}
|
||||
|
||||
if default_metadata:
|
||||
for model in models:
|
||||
|
|
|
|||
|
|
@ -15,16 +15,19 @@ class SecurityHeadersMiddleware:
|
|||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
# Headers derive only from env vars, which are static for the process
|
||||
# lifetime — compute them once instead of per response.
|
||||
self._headers = list(set_security_headers().items())
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope['type'] != 'http':
|
||||
if scope['type'] != 'http' or not self._headers:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_security_headers(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
headers = MutableHeaders(scope=message)
|
||||
for key, value in set_security_headers().items():
|
||||
for key, value in self._headers:
|
||||
headers[key] = value
|
||||
await send(message)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ alembic==1.18.4
|
|||
|
||||
pycrdt==0.13.1
|
||||
redis
|
||||
hiredis
|
||||
|
||||
APScheduler==3.11.2
|
||||
RestrictedPython==8.2
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ psycopg[binary]==3.3.4
|
|||
alembic==1.18.4
|
||||
|
||||
pycrdt==0.13.1
|
||||
redis==8.0.0
|
||||
redis==8.0.1
|
||||
hiredis==3.4.0
|
||||
|
||||
APScheduler==3.11.2
|
||||
RestrictedPython==8.2
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ dependencies = [
|
|||
"alembic==1.18.4",
|
||||
|
||||
"pycrdt==0.13.1",
|
||||
"redis==8.0.0",
|
||||
"redis==8.0.1",
|
||||
"hiredis==3.4.0",
|
||||
# "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey
|
||||
|
||||
"pytz==2026.2",
|
||||
|
|
@ -151,7 +152,6 @@ all = [
|
|||
"psycopg2-binary==2.9.12",
|
||||
"pgvector==0.4.2",
|
||||
"moto[s3]>=5.0.26",
|
||||
"gcp-storage-emulator>=2024.8.3",
|
||||
"docker~=7.1.0",
|
||||
"pytest~=8.3.2",
|
||||
"pytest-docker~=3.2.5",
|
||||
|
|
|
|||
|
|
@ -920,6 +920,47 @@ export const cloneChatById = async (token: string, id: string, title?: string) =
|
|||
return res;
|
||||
};
|
||||
|
||||
export const forkChatById = async (token: string, id: string, messageId?: string | null) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/fork`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message_id: messageId ?? null
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
if ('detail' in err) {
|
||||
error = err.detail;
|
||||
} else {
|
||||
error = err;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const cloneSharedChatById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -290,17 +290,40 @@ export const getSharedFolders = async (token: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getSharedFolderChats = async (token: string, folderId: string) => {
|
||||
export const getSharedFolderChats = async (
|
||||
token: string,
|
||||
folderId: string,
|
||||
params: {
|
||||
page?: number | null;
|
||||
sortBy?: 'title' | 'updated_at';
|
||||
sortDir?: 'asc' | 'desc';
|
||||
} = {}
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page !== undefined && params.page !== null) {
|
||||
searchParams.append('page', `${params.page}`);
|
||||
}
|
||||
if (params.sortBy) {
|
||||
searchParams.append('sort_by', params.sortBy);
|
||||
}
|
||||
if (params.sortDir) {
|
||||
searchParams.append('sort_dir', params.sortDir);
|
||||
}
|
||||
const query = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats${query ? `?${query}` : ''}`,
|
||||
{
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
let apiType = ''; // '' = chat completions (default), 'responses' = Responses API
|
||||
|
||||
let headers = '';
|
||||
let passthroughParams = '';
|
||||
|
||||
let tags = [];
|
||||
|
||||
|
|
@ -57,12 +58,19 @@
|
|||
|
||||
let loading = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
let showAdvanced = false;
|
||||
|
||||
const inputClass =
|
||||
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
||||
const selectClass =
|
||||
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
||||
|
||||
const parsePassthroughParams = (value: string) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((param) => param.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const verifyOllamaHandler = async () => {
|
||||
// remove trailing slash from url
|
||||
url = url.replace(/\/$/, '');
|
||||
|
|
@ -109,6 +117,7 @@
|
|||
...(provider ? { provider } : {}),
|
||||
...(azure ? { azure: true } : {}),
|
||||
api_version: apiVersion,
|
||||
passthrough_params: parsePassthroughParams(passthroughParams),
|
||||
...(_headers ? { headers: _headers } : {})
|
||||
}
|
||||
},
|
||||
|
|
@ -149,6 +158,7 @@
|
|||
if (azure) {
|
||||
if (!apiVersion) {
|
||||
loading = false;
|
||||
showAdvanced = true;
|
||||
|
||||
toast.error($i18n.t('API Version is required'));
|
||||
return;
|
||||
|
|
@ -163,6 +173,7 @@
|
|||
|
||||
if (modelIds.length === 0) {
|
||||
loading = false;
|
||||
showAdvanced = true;
|
||||
toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -195,6 +206,7 @@
|
|||
connection_type: connectionType,
|
||||
auth_type,
|
||||
headers: headers ? JSON.parse(headers) : undefined,
|
||||
passthrough_params: parsePassthroughParams(passthroughParams),
|
||||
...(provider ? { provider } : {}),
|
||||
...(!ollama && azure ? { azure: true } : {}),
|
||||
...(azure ? { api_version: apiVersion } : {}),
|
||||
|
|
@ -211,6 +223,8 @@
|
|||
key = '';
|
||||
auth_type = 'bearer';
|
||||
prefixId = '';
|
||||
passthroughParams = '';
|
||||
showAdvanced = false;
|
||||
tags = [];
|
||||
modelIds = [];
|
||||
};
|
||||
|
|
@ -228,6 +242,9 @@
|
|||
enable = connection.config?.enable ?? true;
|
||||
tags = connection.config?.tags ?? [];
|
||||
prefixId = connection.config?.prefix_id ?? '';
|
||||
passthroughParams = Array.isArray(connection.config?.passthrough_params)
|
||||
? connection.config.passthrough_params.join(', ')
|
||||
: (connection.config?.passthrough_params ?? '');
|
||||
modelIds = connection.config?.model_ids ?? [];
|
||||
|
||||
if (ollama) {
|
||||
|
|
@ -429,107 +446,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="headers-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Headers')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="w-full text-sm outline-hidden"
|
||||
bind:value={headers}
|
||||
placeholder={$i18n.t('Enter additional headers in JSON format')}
|
||||
required={false}
|
||||
minSize={30}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="prefix-id-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Prefix ID')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
id="prefix-id-input"
|
||||
bind:value={prefixId}
|
||||
placeholder={$i18n.t('Prefix ID')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex flex-row justify-between items-center w-full mt-2">
|
||||
<label
|
||||
for="provider-select"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Provider')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
<select
|
||||
id="provider-select"
|
||||
bind:value={provider}
|
||||
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
|
||||
>
|
||||
<option value="">{$i18n.t('Default')}</option>
|
||||
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
|
||||
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if azure}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="api-version-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('API Version')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="api-version-input"
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={apiVersion}
|
||||
placeholder={$i18n.t('API Version')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex flex-row justify-between items-center w-full mt-1">
|
||||
<label
|
||||
|
|
@ -548,19 +464,7 @@
|
|||
class=" text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{#if apiType === 'responses'}
|
||||
<Tooltip
|
||||
className="flex items-center gap-1"
|
||||
content={$i18n.t(
|
||||
'This feature is currently experimental and may not work as expected.'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
|
||||
>{$i18n.t('Experimental')}</span
|
||||
>
|
||||
|
||||
{$i18n.t('Responses')}
|
||||
</Tooltip>
|
||||
{$i18n.t('Responses')}
|
||||
{:else}
|
||||
{$i18n.t('Chat Completions')}
|
||||
{/if}
|
||||
|
|
@ -569,111 +473,268 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class="mb-1 flex justify-between">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
|
||||
on:click={() => (showAdvanced = !showAdvanced)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
|
||||
>
|
||||
{$i18n.t('Model IDs')}
|
||||
</div>
|
||||
</div>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$i18n.t('Advanced')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if modelIds.length > 0}
|
||||
<ul class="flex flex-col">
|
||||
{#each modelIds as modelId, modelIdx}
|
||||
<li class=" flex gap-2 w-full justify-between items-center">
|
||||
<div class=" text-sm flex-1 py-1 rounded-lg">
|
||||
{modelId}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button
|
||||
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
|
||||
MODELID: modelId
|
||||
})}
|
||||
type="button"
|
||||
on:click={() => {
|
||||
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
|
||||
}}
|
||||
>
|
||||
<Minus strokeWidth="2" className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div
|
||||
class={`text-gray-500 text-xs text-center py-2 px-10
|
||||
`}
|
||||
>
|
||||
{#if ollama}
|
||||
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
|
||||
url: url
|
||||
})}
|
||||
{:else if azure}
|
||||
{$i18n.t('Deployment names are required for Azure OpenAI')}
|
||||
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
|
||||
url: `${url}/openai/deployments`
|
||||
})} -->
|
||||
{:else}
|
||||
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
|
||||
url: url
|
||||
})}
|
||||
{/if}
|
||||
{#if showAdvanced}
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="headers-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Headers')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="w-full text-sm outline-hidden"
|
||||
bind:value={headers}
|
||||
placeholder={$i18n.t('Enter additional headers in JSON format')}
|
||||
required={false}
|
||||
minSize={30}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
|
||||
<input
|
||||
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
|
||||
bind:value={modelId}
|
||||
id="add-model-id-input"
|
||||
placeholder={$i18n.t('Add a model ID')}
|
||||
/>
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="allowed-passthrough-params-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Passthrough params')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Add')}
|
||||
on:click={() => {
|
||||
addModelHandler();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth="2" />
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Comma-separated top-level request parameters this upstream may receive without translation. Use * to allow all captured passthrough params.'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
id="allowed-passthrough-params-input"
|
||||
bind:value={passthroughParams}
|
||||
placeholder={$i18n.t('thinking, output_config')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="prefix-id-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Prefix ID')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
id="prefix-id-input"
|
||||
bind:value={prefixId}
|
||||
placeholder={$i18n.t('Prefix ID')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex flex-row justify-between items-center w-full mt-2">
|
||||
<label
|
||||
for="provider-select"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Provider')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
<select
|
||||
id="provider-select"
|
||||
bind:value={provider}
|
||||
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
|
||||
>
|
||||
<option value="">{$i18n.t('Default')}</option>
|
||||
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
|
||||
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if azure}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="api-version-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('API Version')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="api-version-input"
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={apiVersion}
|
||||
placeholder={$i18n.t('API Version')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class="mb-1 flex justify-between">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}
|
||||
>
|
||||
{$i18n.t('Tags')}
|
||||
>
|
||||
{$i18n.t('Model IDs')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if modelIds.length > 0}
|
||||
<ul class="flex flex-col">
|
||||
{#each modelIds as modelId, modelIdx}
|
||||
<li class=" flex gap-2 w-full justify-between items-center">
|
||||
<div class=" text-sm flex-1 py-1 rounded-lg">
|
||||
{modelId}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button
|
||||
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
|
||||
MODELID: modelId
|
||||
})}
|
||||
type="button"
|
||||
on:click={() => {
|
||||
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
|
||||
}}
|
||||
>
|
||||
<Minus strokeWidth="2" className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div
|
||||
class={`text-gray-500 text-xs text-center py-2 px-10
|
||||
`}
|
||||
>
|
||||
{#if ollama}
|
||||
{$i18n.t(
|
||||
'Leave empty to include all models from "{{url}}/api/tags" endpoint',
|
||||
{
|
||||
url: url
|
||||
}
|
||||
)}
|
||||
{:else if azure}
|
||||
{$i18n.t('Deployment names are required for Azure OpenAI')}
|
||||
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
|
||||
url: `${url}/openai/deployments`
|
||||
})} -->
|
||||
{:else}
|
||||
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
|
||||
url: url
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-0.5">
|
||||
<Tags
|
||||
bind:tags
|
||||
on:add={(e) => {
|
||||
tags = [
|
||||
...tags,
|
||||
{
|
||||
name: e.detail
|
||||
}
|
||||
];
|
||||
}}
|
||||
on:delete={(e) => {
|
||||
tags = tags.filter((tag) => tag.name !== e.detail);
|
||||
}}
|
||||
<div class="flex items-center">
|
||||
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
|
||||
<input
|
||||
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
|
||||
bind:value={modelId}
|
||||
id="add-model-id-input"
|
||||
placeholder={$i18n.t('Add a model ID')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Add')}
|
||||
on:click={() => {
|
||||
addModelHandler();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}
|
||||
>
|
||||
{$i18n.t('Tags')}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-0.5">
|
||||
<Tags
|
||||
bind:tags
|
||||
on:add={(e) => {
|
||||
tags = [
|
||||
...tags,
|
||||
{
|
||||
name: e.detail
|
||||
}
|
||||
];
|
||||
}}
|
||||
on:delete={(e) => {
|
||||
tags = tags.filter((tag) => tag.name !== e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pt-3 text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -33,13 +33,14 @@
|
|||
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import ModelSettingsModal from './Models/ModelSettingsModal.svelte';
|
||||
import ManageModelsModal from './Models/ManageModelsModal.svelte';
|
||||
import ModelDefaultsPanel from './Models/ModelDefaultsPanel.svelte';
|
||||
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
||||
import Eye from '$lib/components/icons/Eye.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import Minus from '$lib/components/icons/Minus.svelte';
|
||||
|
|
@ -47,7 +48,10 @@
|
|||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
||||
import Wrench from '$lib/components/icons/Wrench.svelte';
|
||||
import SettingsIcon from '$lib/components/icons/Settings.svelte';
|
||||
import Pin from '$lib/components/icons/Pin.svelte';
|
||||
import PinSlash from '$lib/components/icons/PinSlash.svelte';
|
||||
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
|
|
@ -83,11 +87,13 @@
|
|||
let filteredModels = [];
|
||||
let selectedModelId = null;
|
||||
|
||||
let showConfigModal = false;
|
||||
let showManageModal = false;
|
||||
let showResetModal = false;
|
||||
let savingModelOrder = false;
|
||||
let savingModelsSettings = false;
|
||||
let modelOrderDirty = false;
|
||||
let modelDefaultsPanel = null;
|
||||
let modelDefaultsDirty = false;
|
||||
|
||||
let viewOption = ''; // '' = All, 'enabled', 'disabled', 'visible', 'hidden'
|
||||
let tags: string[] = [];
|
||||
|
|
@ -322,6 +328,20 @@
|
|||
savingModelOrder = false;
|
||||
};
|
||||
|
||||
const saveModelsSettings = async () => {
|
||||
savingModelsSettings = true;
|
||||
|
||||
if (modelOrderDirty) {
|
||||
await saveModelOrder(modelOrderList);
|
||||
}
|
||||
|
||||
if (modelDefaultsDirty) {
|
||||
await modelDefaultsPanel?.save();
|
||||
}
|
||||
|
||||
savingModelsSettings = false;
|
||||
};
|
||||
|
||||
const saveModelDefaults = async (
|
||||
nextDefaultModelIds: string[],
|
||||
nextDefaultPinnedModelIds: string[],
|
||||
|
|
@ -649,7 +669,6 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<ModelSettingsModal bind:show={showConfigModal} initHandler={init} />
|
||||
<ManageModelsModal bind:show={showManageModal} />
|
||||
|
||||
{#if models !== null}
|
||||
|
|
@ -662,18 +681,6 @@
|
|||
{filteredModels.length}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<Tooltip content={$i18n.t('Settings')}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded-lg text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
|
||||
on:click={() => {
|
||||
showConfigModal = true;
|
||||
}}
|
||||
>
|
||||
<SettingsIcon className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{#if $user?.role === 'admin'}
|
||||
|
|
@ -714,6 +721,12 @@
|
|||
{/if}
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col space-y-1">
|
||||
<ModelDefaultsPanel
|
||||
bind:this={modelDefaultsPanel}
|
||||
bind:dirty={modelDefaultsDirty}
|
||||
initHandler={init}
|
||||
/>
|
||||
|
||||
<div class="flex h-8 shrink-0 items-center w-full gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<div class=" self-center ml-1 mr-3">
|
||||
|
|
@ -1005,6 +1018,79 @@
|
|||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={defaultModelIdSet.has(model.id)
|
||||
? $i18n.t('Remove Selected Model')
|
||||
: $i18n.t('Set as Selected Model')}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 {defaultModelIdSet.has(
|
||||
model.id
|
||||
)
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 dark:hover:text-white'}"
|
||||
type="button"
|
||||
aria-label={defaultModelIdSet.has(model.id)
|
||||
? $i18n.t('Remove Selected Model')
|
||||
: $i18n.t('Set as Selected Model')}
|
||||
on:click={() => {
|
||||
toggleDefaultModelHandler(model);
|
||||
}}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={defaultPinnedModelIdSet.has(model.id)
|
||||
? $i18n.t('Remove Pinned Model')
|
||||
: $i18n.t('Set as Pinned Model')}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 {defaultPinnedModelIdSet.has(
|
||||
model.id
|
||||
)
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 dark:hover:text-white'}"
|
||||
type="button"
|
||||
aria-label={defaultPinnedModelIdSet.has(model.id)
|
||||
? $i18n.t('Remove Pinned Model')
|
||||
: $i18n.t('Set as Pinned Model')}
|
||||
on:click={() => {
|
||||
toggleDefaultPinnedModelHandler(model);
|
||||
}}
|
||||
>
|
||||
{#if defaultPinnedModelIdSet.has(model.id)}
|
||||
<PinSlash className="size-3.5" />
|
||||
{:else}
|
||||
<Pin className="size-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={isPublicModel(model)
|
||||
? $i18n.t('Make Private')
|
||||
: $i18n.t('Make Public')}
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 rounded-xl text-gray-500 hover:bg-black/5 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
type="button"
|
||||
aria-label={isPublicModel(model)
|
||||
? $i18n.t('Make Private')
|
||||
: $i18n.t('Make Public')}
|
||||
on:click={() => {
|
||||
toggleModelPrivacyHandler(model);
|
||||
}}
|
||||
>
|
||||
{#if isPublicModel(model)}
|
||||
<LockClosed className="size-3.5" />
|
||||
{:else}
|
||||
<GlobeAlt className="size-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
|
|
@ -1105,13 +1191,15 @@
|
|||
<button
|
||||
class="flex items-center gap-2 px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
disabled={!modelOrderDirty || savingModelOrder}
|
||||
disabled={(!modelOrderDirty && !modelDefaultsDirty) ||
|
||||
savingModelOrder ||
|
||||
savingModelsSettings}
|
||||
on:click={async () => {
|
||||
await saveModelOrder(modelOrderList);
|
||||
await saveModelsSettings();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
{#if savingModelOrder}
|
||||
{#if savingModelOrder || savingModelsSettings}
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
<script>
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { config as appConfig } from '$lib/stores';
|
||||
import { DEFAULT_CAPABILITIES } from '$lib/constants';
|
||||
import { getModelsConfig, setModelsConfig, setDefaultPromptSuggestions } from '$lib/apis/configs';
|
||||
import { getBackendConfig } from '$lib/apis';
|
||||
|
||||
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
|
||||
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
|
||||
import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
|
||||
import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
|
||||
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
|
||||
|
||||
export let initHandler = () => {};
|
||||
export let dirty = false;
|
||||
|
||||
let config = null;
|
||||
let modelIds = [];
|
||||
let loading = false;
|
||||
let expanded = false;
|
||||
let showCapabilities = false;
|
||||
let showParameters = false;
|
||||
let showPromptSuggestions = false;
|
||||
let savedSnapshot = '';
|
||||
|
||||
let defaultCapabilities = {};
|
||||
let defaultFeatureIds = [];
|
||||
let defaultParams = {};
|
||||
let builtinTools = {};
|
||||
let promptSuggestions = [];
|
||||
|
||||
$: configuredParams = Object.entries(defaultParams ?? {}).filter(
|
||||
([_, value]) => value !== null && value !== '' && value !== undefined
|
||||
);
|
||||
$: enabledCapabilities = Object.entries(defaultCapabilities ?? {}).filter(([_, value]) => value);
|
||||
$: availableFeatures = enabledCapabilities
|
||||
.filter(([key]) => ['web_search', 'code_interpreter', 'image_generation'].includes(key))
|
||||
.map(([key]) => key);
|
||||
|
||||
const getSnapshot = () =>
|
||||
JSON.stringify({
|
||||
defaultCapabilities,
|
||||
defaultFeatureIds,
|
||||
defaultParams: Object.fromEntries(configuredParams),
|
||||
builtinTools,
|
||||
promptSuggestions: promptSuggestions.filter((p) => p.content !== '')
|
||||
});
|
||||
|
||||
const updateDirty = async () => {
|
||||
await tick();
|
||||
dirty = savedSnapshot !== '' && getSnapshot() !== savedSnapshot;
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
loading = true;
|
||||
config = await getModelsConfig(localStorage.token);
|
||||
|
||||
modelIds = config?.MODEL_ORDER_LIST || [];
|
||||
|
||||
const savedMeta = config?.DEFAULT_MODEL_METADATA;
|
||||
if (savedMeta && Object.keys(savedMeta).length > 0) {
|
||||
defaultCapabilities = savedMeta.capabilities ?? { ...DEFAULT_CAPABILITIES };
|
||||
defaultFeatureIds = savedMeta.defaultFeatureIds ?? [];
|
||||
builtinTools = savedMeta.builtinTools ?? {};
|
||||
} else {
|
||||
defaultCapabilities = { ...DEFAULT_CAPABILITIES };
|
||||
defaultFeatureIds = [];
|
||||
builtinTools = {};
|
||||
}
|
||||
|
||||
defaultParams = config?.DEFAULT_MODEL_PARAMS ?? {};
|
||||
promptSuggestions = $appConfig?.default_prompt_suggestions ?? [];
|
||||
savedSnapshot = getSnapshot();
|
||||
dirty = false;
|
||||
loading = false;
|
||||
};
|
||||
|
||||
export const save = async () => {
|
||||
if (loading || !dirty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
capabilities: defaultCapabilities,
|
||||
...(defaultFeatureIds.length > 0 ? { defaultFeatureIds } : {}),
|
||||
...(Object.keys(builtinTools).length > 0 ? { builtinTools } : {})
|
||||
};
|
||||
|
||||
const res = await setModelsConfig(localStorage.token, {
|
||||
DEFAULT_MODELS: config?.DEFAULT_MODELS ?? null,
|
||||
DEFAULT_PINNED_MODELS: config?.DEFAULT_PINNED_MODELS ?? null,
|
||||
MODEL_ORDER_LIST: modelIds,
|
||||
DEFAULT_MODEL_METADATA: metadata,
|
||||
DEFAULT_MODEL_PARAMS: Object.fromEntries(configuredParams)
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
config = res;
|
||||
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
|
||||
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
|
||||
await appConfig.set(await getBackendConfig());
|
||||
savedSnapshot = getSnapshot();
|
||||
dirty = false;
|
||||
|
||||
toast.success($i18n.t('Models configuration saved successfully'));
|
||||
initHandler();
|
||||
return true;
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to save models configuration'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shrink-0">
|
||||
<div class="flex items-center justify-between gap-4 py-0.5">
|
||||
<button
|
||||
class="min-w-0 flex-1 text-left text-xs text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
expanded = !expanded;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Model Defaults')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="shrink-0 text-[0.6875rem] text-gray-400 transition hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
expanded = !expanded;
|
||||
}}
|
||||
>
|
||||
{expanded ? $i18n.t('Close') : $i18n.t('Configure')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if expanded}
|
||||
{#if loading}
|
||||
<div class="py-1 text-xs text-gray-400 dark:text-gray-600">{$i18n.t('Loading...')}</div>
|
||||
{:else}
|
||||
<div class="space-y-1 mt-0.5">
|
||||
<div>
|
||||
<button
|
||||
class="flex w-full items-center justify-between gap-4 py-0.5 text-left"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showCapabilities = !showCapabilities;
|
||||
}}
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('Model Capabilities')}
|
||||
</span>
|
||||
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{showCapabilities ? $i18n.t('Close') : $i18n.t('Configure')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if showCapabilities}
|
||||
<div class="pb-2" on:click={updateDirty} on:change={updateDirty}>
|
||||
<Capabilities bind:capabilities={defaultCapabilities} />
|
||||
|
||||
{#if availableFeatures.length > 0}
|
||||
<div class="mt-4">
|
||||
<DefaultFeatures {availableFeatures} bind:featureIds={defaultFeatureIds} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if defaultCapabilities.builtin_tools}
|
||||
<div class="mt-4">
|
||||
<BuiltinTools bind:builtinTools />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="flex w-full items-center justify-between gap-4 py-0.5 text-left"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showParameters = !showParameters;
|
||||
}}
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('Model Parameters')}
|
||||
</span>
|
||||
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{showParameters ? $i18n.t('Close') : $i18n.t('Configure')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if showParameters}
|
||||
<div
|
||||
class="max-h-[24rem] overflow-y-auto pb-2 pr-1 scrollbar-hover"
|
||||
on:click={updateDirty}
|
||||
on:change={updateDirty}
|
||||
on:input={updateDirty}
|
||||
>
|
||||
<AdvancedParams admin={true} custom={true} bind:params={defaultParams} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="flex w-full items-center justify-between gap-4 py-0.5 text-left"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showPromptSuggestions = !showPromptSuggestions;
|
||||
}}
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{$i18n.t('Prompt Suggestions')}
|
||||
</span>
|
||||
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{showPromptSuggestions ? $i18n.t('Close') : $i18n.t('Configure')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if showPromptSuggestions}
|
||||
<div class="pb-2" on:click={updateDirty} on:change={updateDirty} on:input={updateDirty}>
|
||||
<PromptSuggestions bind:promptSuggestions />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
<script>
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { config as _config } from '$lib/stores';
|
||||
import { DEFAULT_CAPABILITIES } from '$lib/constants';
|
||||
import { getModelsConfig, setModelsConfig, setDefaultPromptSuggestions } from '$lib/apis/configs';
|
||||
import { getBackendConfig } from '$lib/apis';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
|
||||
|
||||
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
|
||||
import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
|
||||
import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
|
||||
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
|
||||
|
||||
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
|
||||
|
||||
export let show = false;
|
||||
export let initHandler = () => {};
|
||||
|
||||
let config = null;
|
||||
|
||||
let modelIds = [];
|
||||
|
||||
let loading = false;
|
||||
let showDefaultCapabilities = false;
|
||||
let showDefaultParams = false;
|
||||
let showDefaultPromptSuggestions = false;
|
||||
|
||||
let defaultCapabilities = {};
|
||||
let defaultFeatureIds = [];
|
||||
let defaultParams = {};
|
||||
let builtinTools = {};
|
||||
let promptSuggestions = [];
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
const init = async () => {
|
||||
config = await getModelsConfig(localStorage.token);
|
||||
|
||||
modelIds = config.MODEL_ORDER_LIST || [];
|
||||
|
||||
const savedMeta = config?.DEFAULT_MODEL_METADATA;
|
||||
if (savedMeta && Object.keys(savedMeta).length > 0) {
|
||||
defaultCapabilities = savedMeta.capabilities ?? { ...DEFAULT_CAPABILITIES };
|
||||
defaultFeatureIds = savedMeta.defaultFeatureIds ?? [];
|
||||
builtinTools = savedMeta.builtinTools ?? {};
|
||||
} else {
|
||||
defaultCapabilities = { ...DEFAULT_CAPABILITIES };
|
||||
defaultFeatureIds = [];
|
||||
builtinTools = {};
|
||||
}
|
||||
defaultParams = config?.DEFAULT_MODEL_PARAMS ?? {};
|
||||
|
||||
promptSuggestions = $_config?.default_prompt_suggestions ?? [];
|
||||
};
|
||||
const submitHandler = async () => {
|
||||
loading = true;
|
||||
|
||||
const metadata = {
|
||||
capabilities: defaultCapabilities,
|
||||
...(defaultFeatureIds.length > 0 ? { defaultFeatureIds } : {}),
|
||||
...(Object.keys(builtinTools).length > 0 ? { builtinTools } : {})
|
||||
};
|
||||
|
||||
const res = await setModelsConfig(localStorage.token, {
|
||||
DEFAULT_MODELS: config?.DEFAULT_MODELS ?? null,
|
||||
DEFAULT_PINNED_MODELS: config?.DEFAULT_PINNED_MODELS ?? null,
|
||||
MODEL_ORDER_LIST: modelIds,
|
||||
DEFAULT_MODEL_METADATA: metadata,
|
||||
DEFAULT_MODEL_PARAMS: Object.fromEntries(
|
||||
Object.entries(defaultParams).filter(([_, v]) => v !== null && v !== '' && v !== undefined)
|
||||
)
|
||||
});
|
||||
|
||||
if (res) {
|
||||
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
|
||||
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
|
||||
await _config.set(await getBackendConfig());
|
||||
|
||||
toast.success($i18n.t('Models configuration saved successfully'));
|
||||
initHandler();
|
||||
show = false;
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to save models configuration'));
|
||||
}
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal size="lg" bind:show className="bg-white dark:bg-gray-900 rounded-4xl">
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
|
||||
<div class=" text-sm font-medium self-center">
|
||||
{$i18n.t('Settings')}
|
||||
</div>
|
||||
<button
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<XMark className={'size-4'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
{#if config}
|
||||
<form
|
||||
class="flex flex-col w-full"
|
||||
on:submit|preventDefault={() => {
|
||||
submitHandler();
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
|
||||
<div
|
||||
id="admin-settings-tabs-container"
|
||||
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
|
||||
>
|
||||
<button
|
||||
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition"
|
||||
type="button"
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<AdjustmentsHorizontal />
|
||||
</div>
|
||||
<div class=" self-center">{$i18n.t('Defaults')}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] flex flex-col min-w-0">
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden scrollbar-hidden">
|
||||
<div>
|
||||
<button
|
||||
class="flex w-full justify-between items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showDefaultPromptSuggestions = !showDefaultPromptSuggestions;
|
||||
}}
|
||||
>
|
||||
<div class="text-xs text-gray-500 font-normal">
|
||||
{$i18n.t('Prompt Suggestions')}
|
||||
</div>
|
||||
<div>
|
||||
{#if showDefaultPromptSuggestions}
|
||||
<ChevronUp className="size-3" />
|
||||
{:else}
|
||||
<ChevronDown className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if showDefaultPromptSuggestions}
|
||||
<div class="mt-2">
|
||||
<PromptSuggestions bind:promptSuggestions />
|
||||
|
||||
{#if promptSuggestions.length > 0}
|
||||
<div class="text-xs text-left w-full mt-2 text-gray-500">
|
||||
{$i18n.t(
|
||||
'Adjusting these settings will apply changes universally to all users.'
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="flex w-full justify-between items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showDefaultCapabilities = !showDefaultCapabilities;
|
||||
}}
|
||||
>
|
||||
<div class="text-xs text-gray-500 font-normal">
|
||||
{$i18n.t('Model Capabilities')}
|
||||
</div>
|
||||
<div>
|
||||
{#if showDefaultCapabilities}
|
||||
<ChevronUp className="size-3" />
|
||||
{:else}
|
||||
<ChevronDown className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if showDefaultCapabilities}
|
||||
<div class="mt-2">
|
||||
<Capabilities bind:capabilities={defaultCapabilities} />
|
||||
|
||||
{#if Object.keys(defaultCapabilities).filter((key) => defaultCapabilities[key]).length > 0}
|
||||
{@const availableFeatures = Object.entries(defaultCapabilities)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
value &&
|
||||
['web_search', 'code_interpreter', 'image_generation'].includes(key)
|
||||
)
|
||||
.map(([key, value]) => key)}
|
||||
|
||||
{#if availableFeatures.length > 0}
|
||||
<div class="mt-4">
|
||||
<DefaultFeatures
|
||||
{availableFeatures}
|
||||
bind:featureIds={defaultFeatureIds}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if defaultCapabilities.builtin_tools}
|
||||
<div class="mt-4">
|
||||
<BuiltinTools bind:builtinTools />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="flex w-full justify-between items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showDefaultParams = !showDefaultParams;
|
||||
}}
|
||||
>
|
||||
<div class="text-xs text-gray-500 font-normal">
|
||||
{$i18n.t('Model Parameters')}
|
||||
</div>
|
||||
<div>
|
||||
{#if showDefaultParams}
|
||||
<ChevronUp className="size-3" />
|
||||
{:else}
|
||||
<ChevronDown className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if showDefaultParams}
|
||||
<div class="mt-2">
|
||||
<AdvancedParams admin={true} custom={true} bind:params={defaultParams} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end items-center pt-3 text-sm font-normal gap-1.5">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div>
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
@ -70,10 +70,10 @@
|
|||
|
||||
import {
|
||||
archiveChatById,
|
||||
cloneChatById,
|
||||
compactChatById,
|
||||
createNewChat,
|
||||
deleteChatById,
|
||||
forkChatById,
|
||||
getAllTags,
|
||||
getChatById,
|
||||
getTagsById,
|
||||
|
|
@ -1177,7 +1177,7 @@
|
|||
}
|
||||
|
||||
const pageSubscribe = page.subscribe(async (p) => {
|
||||
if (p.url.pathname === '/') {
|
||||
if (p.url.pathname === '/' || p.url.pathname.startsWith('/folders/')) {
|
||||
await tick();
|
||||
initNewChat();
|
||||
}
|
||||
|
|
@ -1884,6 +1884,9 @@
|
|||
(chatContent?.history ?? undefined) !== undefined
|
||||
? chatContent.history
|
||||
: convertMessagesToHistory(chatContent.messages);
|
||||
if (chat?.current_message_id && history?.messages?.[chat.current_message_id]) {
|
||||
history.currentId = chat.current_message_id;
|
||||
}
|
||||
|
||||
// Sanitize history: repair orphaned references and structurally-malformed
|
||||
// nodes from failed regenerations (#24424, #24157, #20474)
|
||||
|
|
@ -2466,7 +2469,7 @@
|
|||
document.getElementById('chat-input')?.focus();
|
||||
};
|
||||
|
||||
const handleForkChat = async () => {
|
||||
const handleForkChat = async (messageId: string | null = null) => {
|
||||
if (!$chatId || !history?.currentId) {
|
||||
toast.message($i18n.t('No chat to fork'));
|
||||
return;
|
||||
|
|
@ -2489,13 +2492,7 @@
|
|||
const toastId = toast.loading($i18n.t('Forking chat...'));
|
||||
|
||||
try {
|
||||
const result = await cloneChatById(
|
||||
localStorage.token,
|
||||
$chatId,
|
||||
$i18n.t('Clone of {{TITLE}}', {
|
||||
TITLE: $chatTitle || 'Chat'
|
||||
})
|
||||
);
|
||||
const result = await forkChatById(localStorage.token, $chatId, messageId ?? history.currentId);
|
||||
|
||||
if (result?.id) {
|
||||
if (!embedded) {
|
||||
|
|
@ -3725,6 +3722,7 @@
|
|||
{mergeResponses}
|
||||
{chatActionHandler}
|
||||
{addMessages}
|
||||
forkHandler={generating || taskIds?.length ? null : handleForkChat}
|
||||
topPadding={!embedded}
|
||||
bottomPadding={files.length > 0}
|
||||
{onSelect}
|
||||
|
|
|
|||
|
|
@ -183,10 +183,10 @@
|
|||
</button>
|
||||
</Tooltip>
|
||||
{:else if item.data.id === 'fork'}
|
||||
<Tooltip content="Clone this chat into a new copy." placement="top">
|
||||
<Tooltip content="Fork the current chat branch into a new chat." placement="top">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fork: clone this chat into a new copy."
|
||||
aria-label="Fork: fork the current chat branch into a new chat."
|
||||
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
|
||||
{commandIdx === selectedIdx ? 'app-interactive-active' : ''} disabled:opacity-50"
|
||||
disabled={forkDisabled}
|
||||
|
|
@ -222,7 +222,7 @@
|
|||
</span>
|
||||
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
|
||||
<span class="truncate">Fork</span>
|
||||
<span class="app-muted text-[0.625rem] truncate shrink-0">Clone chat</span>
|
||||
<span class="app-muted text-[0.625rem] truncate shrink-0">Current branch</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -40,13 +40,16 @@
|
|||
export let showMessage: Function = () => {};
|
||||
export let submitMessage: Function = () => {};
|
||||
export let addMessages: Function = () => {};
|
||||
export let forkHandler: Function | null = null;
|
||||
|
||||
export let readOnly = false;
|
||||
export let preview = false;
|
||||
export let editCodeBlock = true;
|
||||
|
||||
export let topPadding = false;
|
||||
export let bottomPadding = false;
|
||||
export let autoScroll;
|
||||
export let messagesContainerId = 'messages-container';
|
||||
|
||||
export let onSelect = (e) => {};
|
||||
export let onInsertToNote: ((content: string) => void) | null = null;
|
||||
|
|
@ -54,14 +57,18 @@
|
|||
export let messagesCount: number | null = 8;
|
||||
let messagesLoading = false;
|
||||
|
||||
const getMessagesContainer = () => document.getElementById(messagesContainerId);
|
||||
|
||||
onDestroy(() => {
|
||||
cancelAnimationFrame(pendingRebuild);
|
||||
});
|
||||
|
||||
const loadMoreMessages = async () => {
|
||||
// scroll slightly down to disable continuous loading
|
||||
const element = document.getElementById('messages-container');
|
||||
element.scrollTop = element.scrollTop + 100;
|
||||
const element = getMessagesContainer();
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollTop + 100;
|
||||
}
|
||||
|
||||
messagesLoading = true;
|
||||
messagesCount += 8;
|
||||
|
|
@ -133,7 +140,7 @@
|
|||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const element = document.getElementById('messages-container');
|
||||
const element = getMessagesContainer();
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
|
||||
|
|
@ -213,8 +220,10 @@
|
|||
|
||||
// Optional auto-scroll
|
||||
if ($settings?.scrollOnBranchChange ?? true) {
|
||||
const element = document.getElementById('messages-container');
|
||||
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
|
||||
const element = getMessagesContainer();
|
||||
autoScroll = element
|
||||
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
|
||||
: false;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
|
|
@ -260,8 +269,10 @@
|
|||
await tick();
|
||||
|
||||
if ($settings?.scrollOnBranchChange ?? true) {
|
||||
const element = document.getElementById('messages-container');
|
||||
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
|
||||
const element = getMessagesContainer();
|
||||
autoScroll = element
|
||||
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
|
||||
: false;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
|
|
@ -311,8 +322,10 @@
|
|||
await tick();
|
||||
|
||||
if ($settings?.scrollOnBranchChange ?? true) {
|
||||
const element = document.getElementById('messages-container');
|
||||
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
|
||||
const element = getMessagesContainer();
|
||||
autoScroll = element
|
||||
? element.scrollHeight - element.scrollTop <= element.clientHeight + 50
|
||||
: false;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
|
|
@ -483,11 +496,13 @@
|
|||
|
||||
const triggerScroll = () => {
|
||||
if (autoScroll) {
|
||||
const element = document.getElementById('messages-container');
|
||||
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
const element = getMessagesContainer();
|
||||
if (element) {
|
||||
autoScroll = element.scrollHeight - element.scrollTop <= element.clientHeight + 50;
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -539,8 +554,10 @@
|
|||
{continueResponse}
|
||||
{mergeResponses}
|
||||
{addMessages}
|
||||
{forkHandler}
|
||||
{triggerScroll}
|
||||
{readOnly}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{onInsertToNote}
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@
|
|||
{#if extracted.detailsContent}
|
||||
<!-- Render structural blocks (tool calls, reasoning, etc.) through Markdown -->
|
||||
<div class="markdown-prose">
|
||||
<Markdown {id} content={extracted.detailsContent} {done} />
|
||||
<Markdown {id} content={extracted.detailsContent} {preview} {done} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if extracted.plainContent}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
|
||||
export let messageDone = true;
|
||||
export let allowEmbeds = true;
|
||||
export let preview = false;
|
||||
|
||||
let open = $settings?.expandDetails ?? false;
|
||||
|
||||
|
|
@ -112,7 +113,9 @@
|
|||
<div {id} class="w-full">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<button
|
||||
class="w-fit py-0.5 text-left text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
|
||||
class="w-fit py-0.5 text-left {preview
|
||||
? 'text-xs'
|
||||
: 'text-[0.9375rem]'} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
|
||||
aria-label={$i18n.t('Toggle details')}
|
||||
aria-expanded={open}
|
||||
on:click={() => {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@
|
|||
.trim();
|
||||
};
|
||||
|
||||
$: detailButtonClassName = `w-fit py-0.5 ${
|
||||
preview ? 'text-xs' : 'text-[0.9375rem]'
|
||||
} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`;
|
||||
|
||||
$: displayTokens = getDisplayTokens(tokens);
|
||||
$: singlePlainBlock =
|
||||
displayTokens.length === 1 &&
|
||||
|
|
@ -278,6 +282,7 @@
|
|||
id={`${id}-${tokenIdx}`}
|
||||
tokens={token.tokens}
|
||||
{done}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{onTaskClick}
|
||||
{sourceIds}
|
||||
|
|
@ -313,6 +318,7 @@
|
|||
tokens={item.tokens}
|
||||
top={token.loose}
|
||||
{done}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{onTaskClick}
|
||||
{sourceIds}
|
||||
|
|
@ -348,6 +354,7 @@
|
|||
tokens={item.tokens}
|
||||
top={token.loose}
|
||||
{done}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{onTaskClick}
|
||||
{sourceIds}
|
||||
|
|
@ -360,6 +367,7 @@
|
|||
tokens={item.tokens}
|
||||
top={token.loose}
|
||||
{done}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{onTaskClick}
|
||||
{sourceIds}
|
||||
|
|
@ -375,6 +383,7 @@
|
|||
id={`${id}-${tokenIdx}-detail-group`}
|
||||
tokens={token.items}
|
||||
messageDone={done}
|
||||
{preview}
|
||||
{allowEmbeds}
|
||||
>
|
||||
<div slot="content">
|
||||
|
|
@ -389,7 +398,7 @@
|
|||
grouped={true}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full"
|
||||
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
||||
buttonClassName={detailButtonClassName}
|
||||
/>
|
||||
{:else if textContent.length > 0}
|
||||
<Collapsible
|
||||
|
|
@ -398,7 +407,7 @@
|
|||
attributes={detailToken?.attributes}
|
||||
messageDone={done}
|
||||
className="w-full"
|
||||
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
||||
buttonClassName={detailButtonClassName}
|
||||
dir="auto"
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
|
|
@ -407,6 +416,7 @@
|
|||
tokens={marked.lexer(decode(detailToken.text))}
|
||||
attributes={detailToken?.attributes}
|
||||
{done}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{onTaskClick}
|
||||
{sourceIds}
|
||||
|
|
@ -422,7 +432,7 @@
|
|||
attributes={detailToken?.attributes}
|
||||
messageDone={done}
|
||||
className="w-full"
|
||||
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
||||
buttonClassName={detailButtonClassName}
|
||||
dir="auto"
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -440,6 +450,7 @@
|
|||
resultContent={getDetailTextContent(token)}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-2"
|
||||
buttonClassName={detailButtonClassName}
|
||||
/>
|
||||
{:else if textContent.length > 0}
|
||||
<Collapsible
|
||||
|
|
@ -448,6 +459,7 @@
|
|||
attributes={token?.attributes}
|
||||
messageDone={done}
|
||||
className="w-full space-y-2"
|
||||
buttonClassName={detailButtonClassName}
|
||||
dir="auto"
|
||||
>
|
||||
<div class=" mb-1.5" slot="content">
|
||||
|
|
@ -456,6 +468,7 @@
|
|||
tokens={marked.lexer(decode(token.text))}
|
||||
attributes={token?.attributes}
|
||||
{done}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{onTaskClick}
|
||||
{sourceIds}
|
||||
|
|
@ -471,6 +484,7 @@
|
|||
attributes={token?.attributes}
|
||||
messageDone={done}
|
||||
className="w-full space-y-2"
|
||||
buttonClassName={detailButtonClassName}
|
||||
dir="auto"
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,10 @@
|
|||
export let mergeResponses;
|
||||
|
||||
export let addMessages;
|
||||
export let forkHandler: Function | null = null;
|
||||
export let triggerScroll;
|
||||
export let readOnly = false;
|
||||
export let preview = false;
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
export let onInsertToNote: ((content: string) => void) | null = null;
|
||||
|
|
@ -71,6 +73,7 @@
|
|||
{editMessage}
|
||||
{deleteMessage}
|
||||
{readOnly}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{onInsertToNote}
|
||||
|
|
@ -97,7 +100,9 @@
|
|||
{continueResponse}
|
||||
{regenerateResponse}
|
||||
{addMessages}
|
||||
{forkHandler}
|
||||
{readOnly}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
/>
|
||||
|
|
@ -122,7 +127,9 @@
|
|||
{mergeResponses}
|
||||
{triggerScroll}
|
||||
{addMessages}
|
||||
{forkHandler}
|
||||
{readOnly}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{onInsertToNote}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
|
||||
export let isLastMessage;
|
||||
export let readOnly = false;
|
||||
export let preview = false;
|
||||
export let editCodeBlock = true;
|
||||
|
||||
export let setInputText: Function = () => {};
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
export let mergeResponses: Function;
|
||||
|
||||
export let addMessages: Function;
|
||||
export let forkHandler: Function | null = null;
|
||||
|
||||
export let triggerScroll: Function;
|
||||
|
||||
|
|
@ -250,7 +252,11 @@
|
|||
>
|
||||
{#if $settings?.displayMultiModelResponsesInTabs ?? false}
|
||||
<div class="w-full">
|
||||
<div class=" flex w-full mb-4.5 border-b border-gray-200 dark:border-gray-850">
|
||||
<div
|
||||
class=" flex w-full mb-4.5 border-b border-gray-200 dark:border-gray-850 {preview
|
||||
? 'hidden'
|
||||
: ''}"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 scrollbar-none overflow-x-auto w-fit text-center font-normal bg-transparent pt-1 text-sm"
|
||||
on:wheel|preventDefault={(e) => {
|
||||
|
|
@ -319,7 +325,9 @@
|
|||
groupedMessageIds[selectedModelIdx].messageIds.length - 1;
|
||||
}}
|
||||
{addMessages}
|
||||
{forkHandler}
|
||||
{readOnly}
|
||||
{preview}
|
||||
{topPadding}
|
||||
{onInsertToNote}
|
||||
/>
|
||||
|
|
@ -336,14 +344,17 @@
|
|||
groupedMessageIds[modelIdx].messageIds[groupedMessageIdsIdx[modelIdx]]}
|
||||
|
||||
<div
|
||||
class=" snap-center w-full max-w-full m-1 border {history.messages[messageId]
|
||||
?.modelIdx == modelIdx
|
||||
? `bg-gray-50 dark:bg-gray-850 border-gray-100 dark:border-gray-800 border-2 ${
|
||||
$mobile ? 'min-w-full' : 'min-w-80'
|
||||
}`
|
||||
: `border-gray-100/30 dark:border-gray-850/30 border-dashed ${
|
||||
$mobile ? 'min-w-full' : 'min-w-80'
|
||||
}`} transition-all p-5 rounded-2xl"
|
||||
class="snap-center w-full max-w-full transition-all {preview
|
||||
? ''
|
||||
: `m-1 border p-5 rounded-2xl ${
|
||||
history.messages[messageId]?.modelIdx == modelIdx
|
||||
? `bg-gray-50 dark:bg-gray-850 border-gray-100 dark:border-gray-800 border-2 ${
|
||||
$mobile ? 'min-w-full' : 'min-w-80'
|
||||
}`
|
||||
: `border-gray-100/30 dark:border-gray-850/30 border-dashed ${
|
||||
$mobile ? 'min-w-full' : 'min-w-80'
|
||||
}`
|
||||
}`}"
|
||||
on:click={async () => {
|
||||
onGroupClick(_messageId, modelIdx);
|
||||
}}
|
||||
|
|
@ -376,7 +387,9 @@
|
|||
groupedMessageIds[modelIdx].messageIds.length - 1;
|
||||
}}
|
||||
{addMessages}
|
||||
{forkHandler}
|
||||
{readOnly}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{onInsertToNote}
|
||||
|
|
@ -389,7 +402,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !readOnly}
|
||||
{#if !preview && !readOnly}
|
||||
{#if !Object.keys(groupedMessageIds).find((modelIdx) => {
|
||||
const { messageIds } = groupedMessageIds[modelIdx];
|
||||
const _messageId = messageIds[groupedMessageIdsIdx[modelIdx]];
|
||||
|
|
|
|||
|
|
@ -159,11 +159,13 @@
|
|||
export let submitMessage: Function;
|
||||
export let continueResponse: Function;
|
||||
export let regenerateResponse: Function;
|
||||
export let forkHandler: Function | null = null;
|
||||
|
||||
export let addMessages: Function;
|
||||
|
||||
export let isLastMessage = true;
|
||||
export let readOnly = false;
|
||||
export let preview = false;
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
export let onInsertToNote: ((content: string) => void) | null = null;
|
||||
|
|
@ -664,13 +666,15 @@
|
|||
</div>
|
||||
|
||||
<div class="flex-auto w-0 pl-1 relative">
|
||||
<Name>
|
||||
<Tooltip content={model?.name ?? message.model} placement="top-start">
|
||||
<span id="response-message-model-name" class="line-clamp-1 text-black dark:text-white">
|
||||
{model?.name ?? message.model}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Name>
|
||||
{#if !preview}
|
||||
<Name>
|
||||
<Tooltip content={model?.name ?? message.model} placement="top-start">
|
||||
<span id="response-message-model-name" class="line-clamp-1 text-black dark:text-white">
|
||||
{model?.name ?? message.model}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Name>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="chat-{message.role} w-full min-w-full">
|
||||
|
|
@ -821,7 +825,7 @@
|
|||
!readOnly &&
|
||||
($settings?.showFloatingActionButtons ?? true)}
|
||||
save={!readOnly}
|
||||
preview={!readOnly}
|
||||
preview={preview || !readOnly}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
done={($settings?.chatFadeStreamingText ?? true)
|
||||
|
|
@ -890,7 +894,22 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if !edit}
|
||||
{#if preview && message.timestamp}
|
||||
<div class="mt-0.5 flex justify-start whitespace-nowrap text-gray-600 dark:text-gray-500">
|
||||
<Tooltip
|
||||
className="flex self-center"
|
||||
content={formatMessageTimestampFull(message.timestamp * 1000)}
|
||||
placement="bottom"
|
||||
>
|
||||
<time
|
||||
datetime={new Date(message.timestamp * 1000).toISOString()}
|
||||
class="ml-1 shrink-0 whitespace-nowrap text-[0.6875rem] tabular-nums text-gray-400 dark:text-gray-600 select-none"
|
||||
>
|
||||
{formatMessageTimestamp(message.timestamp * 1000)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else if !edit}
|
||||
<div
|
||||
bind:this={buttonsContainerElement}
|
||||
class="flex items-center justify-start overflow-x-auto whitespace-nowrap buttons text-gray-600 dark:text-gray-500 mt-0.5 [&>*]:shrink-0"
|
||||
|
|
@ -1054,6 +1073,37 @@
|
|||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{#if message.done && !readOnly && forkHandler}
|
||||
<Tooltip content="Fork chat" placement="bottom">
|
||||
<button
|
||||
aria-label="Fork chat"
|
||||
class="{isLastMessage || ($settings?.highContrastMode ?? false)
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
|
||||
on:click={() => {
|
||||
forkHandler?.(message.id);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 12H9" />
|
||||
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
|
||||
<path d="M17 4L20 7L17 10" />
|
||||
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
|
||||
<path d="M17 14L20 17L17 20" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if onInsertToNote && visibleResponseContent}
|
||||
<Tooltip content={$i18n.t('Insert into note')} placement="bottom">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@
|
|||
const getDetailTitle = (detailToken: OutputDetailToken): any => detailToken.summary;
|
||||
const getDetailAttributes = (detailToken: OutputDetailToken): any => detailToken.attributes;
|
||||
|
||||
$: detailButtonClassName = `w-fit py-0.5 ${
|
||||
preview ? 'text-xs' : 'text-[0.9375rem]'
|
||||
} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`;
|
||||
|
||||
$: displayItems = buildOutputDisplayItems(output) as OutputDisplayItem[];
|
||||
</script>
|
||||
|
||||
|
|
@ -64,6 +68,7 @@
|
|||
id={`${id}-${displayItem.id}`}
|
||||
tokens={displayItem.tokens}
|
||||
messageDone={done}
|
||||
{preview}
|
||||
>
|
||||
<div slot="content">
|
||||
{#each displayItem.tokens as detailToken, detailIndex}
|
||||
|
|
@ -75,7 +80,7 @@
|
|||
grouped={true}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full"
|
||||
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
||||
buttonClassName={detailButtonClassName}
|
||||
/>
|
||||
{:else if detailToken.text?.length > 0}
|
||||
<Collapsible
|
||||
|
|
@ -84,7 +89,7 @@
|
|||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full"
|
||||
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
||||
buttonClassName={detailButtonClassName}
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
<div class="markdown-prose">
|
||||
|
|
@ -92,6 +97,7 @@
|
|||
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -105,7 +111,7 @@
|
|||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full"
|
||||
buttonClassName="w-fit py-0.5 text-[0.9375rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
||||
buttonClassName={detailButtonClassName}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
|
@ -120,6 +126,7 @@
|
|||
resultContent={detailToken.text}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-2"
|
||||
buttonClassName={detailButtonClassName}
|
||||
/>
|
||||
{:else if detailToken.text?.length > 0}
|
||||
<Collapsible
|
||||
|
|
@ -128,6 +135,7 @@
|
|||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-2"
|
||||
buttonClassName={detailButtonClassName}
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
<div class="markdown-prose">
|
||||
|
|
@ -135,6 +143,7 @@
|
|||
id={`${id}-${displayItem.id}-detail`}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{preview}
|
||||
{editCodeBlock}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -148,6 +157,7 @@
|
|||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-2"
|
||||
buttonClassName={detailButtonClassName}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
|
||||
export let isFirstMessage: boolean;
|
||||
export let readOnly: boolean;
|
||||
export let preview = false;
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
|
||||
|
|
@ -415,7 +416,8 @@
|
|||
>
|
||||
<time
|
||||
datetime={new Date(message.timestamp * 1000).toISOString()}
|
||||
class="invisible group-hover:visible {($settings?.chatBubble ?? true)
|
||||
class="{preview ? '' : 'invisible group-hover:visible'} {($settings?.chatBubble ??
|
||||
true)
|
||||
? 'mr-1'
|
||||
: 'ml-1 shrink-0 whitespace-nowrap'} text-[0.6875rem] tabular-nums text-gray-400 dark:text-gray-600 select-none"
|
||||
>
|
||||
|
|
@ -424,7 +426,7 @@
|
|||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if !($settings?.chatBubble ?? true)}
|
||||
{#if !preview && !($settings?.chatBubble ?? true)}
|
||||
{#if siblings.length > 1}
|
||||
<div class="flex self-center" dir="ltr">
|
||||
<button
|
||||
|
|
@ -518,7 +520,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !readOnly}
|
||||
{#if !preview && !readOnly}
|
||||
<Tooltip content={$i18n.t('Edit')} placement="bottom">
|
||||
<button
|
||||
class="{($settings?.highContrastMode ?? false)
|
||||
|
|
@ -546,7 +548,7 @@
|
|||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if message?.content}
|
||||
{#if !preview && message?.content}
|
||||
<Tooltip content={$i18n.t('Copy')} placement="bottom">
|
||||
<button
|
||||
class="{($settings?.highContrastMode ?? false)
|
||||
|
|
@ -575,7 +577,7 @@
|
|||
{/if}
|
||||
|
||||
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.delete_message ?? false)}
|
||||
{#if !readOnly && (!isFirstMessage || siblings.length > 1)}
|
||||
{#if !preview && !readOnly && (!isFirstMessage || siblings.length > 1)}
|
||||
<Tooltip content={$i18n.t('Delete')} placement="bottom">
|
||||
<button
|
||||
class="{($settings?.highContrastMode ?? false)
|
||||
|
|
@ -608,7 +610,7 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if $settings?.chatBubble ?? true}
|
||||
{#if !preview && ($settings?.chatBubble ?? true)}
|
||||
{#if siblings.length > 1}
|
||||
<div class="flex self-center" dir="ltr">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,27 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
import { getContext } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
const i18n: Writable<any> = getContext('i18n');
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import { getTimeRange } from '$lib/utils';
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import Loader from '$lib/components/common/Loader.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export let chats = [];
|
||||
export let chats: any[] = [];
|
||||
|
||||
export let chatListLoading = false;
|
||||
export let allChatsLoaded = false;
|
||||
|
||||
export let loadHandler: Function = null;
|
||||
export let showOwnerInfo = false;
|
||||
export let page = 1;
|
||||
export let total = 0;
|
||||
export let perPage = 10;
|
||||
export let orderBy: 'title' | 'updated_at' = 'updated_at';
|
||||
export let direction: 'asc' | 'desc' = 'desc';
|
||||
export let onPageChange: Function = () => {};
|
||||
export let onSort: Function = () => {};
|
||||
|
||||
let chatList = null;
|
||||
let chatList: any[] | null = null;
|
||||
let totalPages = 1;
|
||||
let pages: (number | 'ellipsis')[] = [];
|
||||
|
||||
const init = async () => {
|
||||
if (chats.length === 0) {
|
||||
|
|
@ -31,34 +39,43 @@
|
|||
...chat,
|
||||
time_range: getTimeRange(chat.updated_at)
|
||||
}));
|
||||
|
||||
chatList.sort((a, b) => {
|
||||
if (direction === 'asc') {
|
||||
return a[orderBy] > b[orderBy] ? 1 : -1;
|
||||
} else {
|
||||
return a[orderBy] < b[orderBy] ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setSortKey = (key) => {
|
||||
if (orderBy === key) {
|
||||
direction = direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
orderBy = key;
|
||||
direction = 'asc';
|
||||
}
|
||||
|
||||
init();
|
||||
const setSortKey = (key: 'title' | 'updated_at') => {
|
||||
onSort(key);
|
||||
};
|
||||
|
||||
let orderBy = 'updated_at';
|
||||
let direction = 'desc'; // 'asc' or 'desc'
|
||||
const buildPages = (currentPage: number, pageCount: number): (number | 'ellipsis')[] => {
|
||||
if (pageCount <= 7) {
|
||||
return Array.from({ length: pageCount }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const items: (number | 'ellipsis')[] = [1];
|
||||
if (currentPage > 3) {
|
||||
items.push('ellipsis');
|
||||
}
|
||||
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(pageCount - 1, currentPage + 1);
|
||||
for (let i = start; i <= end; i++) {
|
||||
items.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < pageCount - 2) {
|
||||
items.push('ellipsis');
|
||||
}
|
||||
items.push(pageCount);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
$: if (chats) {
|
||||
init();
|
||||
}
|
||||
|
||||
$: totalPages = Math.max(1, Math.ceil(total / perPage));
|
||||
$: pages = buildPages(page, totalPages);
|
||||
</script>
|
||||
|
||||
{#if chatList}
|
||||
|
|
@ -153,7 +170,6 @@
|
|||
class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
|
||||
draggable="false"
|
||||
href={`/c/${chat.id}`}
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
<div class="text-ellipsis line-clamp-1 w-full sm:basis-3/5">
|
||||
{chat?.title}
|
||||
|
|
@ -177,19 +193,48 @@
|
|||
</a>
|
||||
{/each}
|
||||
|
||||
{#if !allChatsLoaded && loadHandler}
|
||||
<Loader
|
||||
on:visible={(e) => {
|
||||
if (!chatListLoading) {
|
||||
loadHandler();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
|
||||
<Spinner className=" size-4" />
|
||||
<div class=" ">Loading...</div>
|
||||
</div>
|
||||
</Loader>
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-center gap-1.5 pt-2">
|
||||
<button
|
||||
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-25 dark:text-gray-500 dark:hover:bg-gray-850 dark:hover:text-gray-300"
|
||||
disabled={chatListLoading || page <= 1}
|
||||
aria-label={$i18n.t('Previous page')}
|
||||
on:click={() => onPageChange(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-3.5" strokeWidth="2" />
|
||||
</button>
|
||||
|
||||
{#each pages as item, index (item === 'ellipsis' ? `ellipsis-${index}` : item)}
|
||||
{#if item === 'ellipsis'}
|
||||
<span
|
||||
class="inline-flex size-7 items-center justify-center text-xs text-gray-400 select-none dark:text-gray-600"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium transition hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300 {item ===
|
||||
page
|
||||
? 'bg-gray-50 text-gray-800 dark:bg-gray-850 dark:text-gray-100'
|
||||
: 'text-gray-500 dark:text-gray-500'}"
|
||||
aria-label={`Page ${item}`}
|
||||
aria-current={item === page ? 'page' : undefined}
|
||||
on:click={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-25 dark:text-gray-500 dark:hover:bg-gray-850 dark:hover:text-gray-300"
|
||||
disabled={chatListLoading || page >= totalPages}
|
||||
aria-label={$i18n.t('Next page')}
|
||||
on:click={() => onPageChange(page + 1)}
|
||||
>
|
||||
<ChevronRight className="size-3.5" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,21 @@
|
|||
import ChatList from './ChatList.svelte';
|
||||
import FolderKnowledge from './FolderKnowledge.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import { getChatListByFolderId } from '$lib/apis/chats';
|
||||
import { getSharedFolderChats } from '$lib/apis/folders';
|
||||
|
||||
export let folder: any = null;
|
||||
|
||||
let selectedTab = 'chats';
|
||||
|
||||
const CHATS_PAGE_SIZE = 10;
|
||||
let page = 1;
|
||||
let totalChats = 0;
|
||||
let orderBy: 'title' | 'updated_at' = 'updated_at';
|
||||
let direction: 'asc' | 'desc' = 'desc';
|
||||
let currentFolderId: string | null = null;
|
||||
|
||||
let chats: any[] | null = null;
|
||||
let chatListLoading = false;
|
||||
let allChatsLoaded = false;
|
||||
|
||||
$: showOwnerInfo = Boolean(
|
||||
folder?.shared ||
|
||||
|
|
@ -30,42 +33,68 @@
|
|||
(folder?.access_grants?.length ?? 0) > 0
|
||||
);
|
||||
|
||||
const loadChats = async () => {
|
||||
// getSharedFolderChats returns all users' chats in one shot; no pagination
|
||||
allChatsLoaded = true;
|
||||
const setSortKey = (key: 'title' | 'updated_at') => {
|
||||
if (orderBy === key) {
|
||||
direction = direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
orderBy = key;
|
||||
direction = key === 'title' ? 'asc' : 'desc';
|
||||
}
|
||||
page = 1;
|
||||
setChatList();
|
||||
};
|
||||
|
||||
const setPage = (nextPage: number) => {
|
||||
if (nextPage === page || chatListLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
page = nextPage;
|
||||
setChatList();
|
||||
};
|
||||
|
||||
const setChatList = async () => {
|
||||
const folderId = folder?.id;
|
||||
chats = null;
|
||||
page = 1;
|
||||
allChatsLoaded = false;
|
||||
chatListLoading = false;
|
||||
|
||||
if (folder && folder.id) {
|
||||
if (folderId) {
|
||||
// Always use the shared folder endpoint so owners also see
|
||||
// chats created by users who have write access to this folder.
|
||||
const res = await getSharedFolderChats(localStorage.token, folder.id).catch((error) => {
|
||||
chatListLoading = true;
|
||||
const res = await getSharedFolderChats(localStorage.token, folderId, {
|
||||
page,
|
||||
sortBy: orderBy,
|
||||
sortDir: direction
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
chatListLoading = false;
|
||||
|
||||
if (res && res.chats) {
|
||||
chats = res.chats;
|
||||
allChatsLoaded = true;
|
||||
totalChats = res.total ?? res.chats.length;
|
||||
} else {
|
||||
// Fallback to regular API (e.g. if user has no shared access)
|
||||
const fallback = await getChatListByFolderId(localStorage.token, folder.id, page).catch(
|
||||
() => []
|
||||
);
|
||||
chats = fallback || [];
|
||||
chats = [];
|
||||
totalChats = 0;
|
||||
}
|
||||
} else {
|
||||
chats = [];
|
||||
totalChats = 0;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (folder) {
|
||||
$: if (folder?.id && folder.id !== currentFolderId) {
|
||||
currentFolderId = folder.id;
|
||||
page = 1;
|
||||
setChatList();
|
||||
}
|
||||
|
||||
$: if (!folder?.id && currentFolderId !== null) {
|
||||
currentFolderId = null;
|
||||
chats = [];
|
||||
totalChats = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
|
|
@ -105,9 +134,14 @@
|
|||
<ChatList
|
||||
{chats}
|
||||
{chatListLoading}
|
||||
{allChatsLoaded}
|
||||
loadHandler={loadChats}
|
||||
{showOwnerInfo}
|
||||
{page}
|
||||
total={totalChats}
|
||||
perPage={CHATS_PAGE_SIZE}
|
||||
{orderBy}
|
||||
{direction}
|
||||
onPageChange={setPage}
|
||||
onSort={setSortKey}
|
||||
/>
|
||||
{:else}
|
||||
<div class="py-10">
|
||||
|
|
|
|||
148
src/lib/components/layout/Sidebar/ChatHoverPreview.svelte
Normal file
148
src/lib/components/layout/Sidebar/ChatHoverPreview.svelte
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onDestroy, tick } from 'svelte';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
|
||||
import { getChatById } from '$lib/apis/chats';
|
||||
import Messages from '$lib/components/chat/Messages.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
const i18n: any = getContext('i18n');
|
||||
|
||||
export let chatId = '';
|
||||
export let title = '';
|
||||
export let openPreview = false;
|
||||
export let side: 'left' | 'right' | 'bottom' | 'top' = 'right';
|
||||
export let align: 'start' | 'end' | 'center' = 'start';
|
||||
export let sideOffset = 12;
|
||||
|
||||
let messagesContainerElement: HTMLElement | null = null;
|
||||
|
||||
let selectedModels = [''];
|
||||
let history: any = null;
|
||||
let previewReady = false;
|
||||
let loading = false;
|
||||
let error = '';
|
||||
let requestedChatId = '';
|
||||
let loadToken = 0;
|
||||
|
||||
$: messagesContainerId = `chat-hover-preview-messages-${chatId}`;
|
||||
|
||||
const scrollPreviewToBottom = async () => {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
}
|
||||
}, 80);
|
||||
};
|
||||
|
||||
const loadChatPreview = async (id: string) => {
|
||||
if (!id || loading || requestedChatId === id) return;
|
||||
|
||||
const token = ++loadToken;
|
||||
requestedChatId = id;
|
||||
loading = true;
|
||||
error = '';
|
||||
previewReady = false;
|
||||
history = null;
|
||||
selectedModels = [''];
|
||||
|
||||
const chat = await getChatById(localStorage.token, id).catch(() => null);
|
||||
if (token !== loadToken) return;
|
||||
|
||||
if (chat?.chat?.history) {
|
||||
selectedModels = Array.isArray(chat.chat.models)
|
||||
? chat.chat.models
|
||||
: [chat.chat.models ?? ''];
|
||||
history = chat.chat.history;
|
||||
previewReady = true;
|
||||
scrollPreviewToBottom();
|
||||
} else if (chat) {
|
||||
previewReady = true;
|
||||
} else {
|
||||
error = $i18n.t('Failed to load chat preview');
|
||||
}
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
$: if (openPreview && chatId && requestedChatId !== chatId) {
|
||||
loadChatPreview(chatId);
|
||||
}
|
||||
|
||||
$: if (openPreview && previewReady) {
|
||||
scrollPreviewToBottom();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
loadToken += 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if openPreview}
|
||||
<LinkPreview.Portal>
|
||||
<LinkPreview.Content
|
||||
class="z-[9999] hidden max-h-[min(17.5rem,calc(100vh-1.5rem))] w-[20rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-2xl bg-white shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 transition md:block dark:bg-gray-850 dark:text-white dark:ring-white/10"
|
||||
{side}
|
||||
{align}
|
||||
{sideOffset}
|
||||
>
|
||||
<div class="border-b border-gray-50/60 px-3 py-2 dark:border-gray-800/25">
|
||||
<div class="truncate text-[13px] font-medium leading-5 text-gray-900 dark:text-gray-100">
|
||||
{title || $i18n.t('Chat')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id={messagesContainerId}
|
||||
bind:this={messagesContainerElement}
|
||||
class="max-h-[min(15rem,calc(100vh-4rem))] overflow-y-auto bg-white scrollbar-hover @container dark:bg-gray-850"
|
||||
>
|
||||
{#if loading}
|
||||
<div
|
||||
class="flex w-full items-center justify-center py-8 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="flex w-full items-center justify-center px-6 py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{:else if previewReady}
|
||||
<Messages
|
||||
className="flex w-full pt-2 pb-0 [&_.message-listitem]:!mb-1 [&_.message-listitem]:!max-w-none [&_.message-listitem]:!px-3 [&_.pb-18]:!pb-1.5 [&_.markdown-prose]:!text-xs [&_.markdown-prose]:!leading-snug [&_.whitespace-pre-wrap]:!text-xs [&_.whitespace-pre-wrap]:!leading-snug [&_.text-\[0\.9375rem\]]:!text-xs [&_.text-sm]:!text-xs [&_.tool-call-body_pre]:!text-[11px] [&_.rounded-3xl]:!rounded-2xl [&_.chat-user_.rounded-3xl]:!bg-gray-50 dark:[&_.chat-user_.rounded-3xl]:!bg-gray-800 [&_.px-4]:!px-3 [&_.py-3]:!py-2 [&_.py-1\.5]:!py-1"
|
||||
chatId={`chat-hover-preview-${chatId}`}
|
||||
user={$user}
|
||||
prompt=""
|
||||
readOnly={true}
|
||||
preview={true}
|
||||
{selectedModels}
|
||||
atSelectedModel={null}
|
||||
{history}
|
||||
autoScroll={true}
|
||||
{messagesContainerId}
|
||||
messagesCount={8}
|
||||
sendMessage={() => {}}
|
||||
continueResponse={() => {}}
|
||||
regenerateResponse={() => {}}
|
||||
mergeResponses={() => {}}
|
||||
chatActionHandler={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</LinkPreview.Content>
|
||||
</LinkPreview.Portal>
|
||||
{/if}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
import { toast } from 'svelte-sonner';
|
||||
import { goto, invalidate, invalidateAll } from '$app/navigation';
|
||||
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
import {
|
||||
archiveChatById,
|
||||
cloneChatById,
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import DragGhost from '$lib/components/common/DragGhost.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import ChatHoverPreview from './ChatHoverPreview.svelte';
|
||||
import ChatIcon from './icons/Chat.svelte';
|
||||
import MoreHorizontalIcon from './icons/MoreHorizontal.svelte';
|
||||
import SparklesIcon from './icons/Sparkles.svelte';
|
||||
|
|
@ -90,6 +92,7 @@
|
|||
let chat = null;
|
||||
|
||||
let mouseOver = false;
|
||||
let openPreview = false;
|
||||
|
||||
// Local state: tracks the last updatedAt seen while the user was viewing
|
||||
// this chat. Survives prop refreshes from sidebar data re-fetches that
|
||||
|
|
@ -247,6 +250,7 @@
|
|||
|
||||
const onDragStart = (event) => {
|
||||
event.stopPropagation();
|
||||
openPreview = false;
|
||||
|
||||
event.dataTransfer.setDragImage(invisibleDragImage, 0, 0);
|
||||
|
||||
|
|
@ -329,6 +333,7 @@
|
|||
const renameHandler = async () => {
|
||||
chatTitle = title;
|
||||
confirmEdit = true;
|
||||
openPreview = false;
|
||||
|
||||
await tick();
|
||||
|
||||
|
|
@ -444,11 +449,18 @@
|
|||
</DragGhost>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
id="sidebar-chat-group"
|
||||
bind:this={itemElement}
|
||||
class=" w-full {className} relative group"
|
||||
draggable={!confirmEdit && !readonly}
|
||||
on:mouseenter={() => {
|
||||
mouseOver = true;
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
mouseOver = false;
|
||||
}}
|
||||
>
|
||||
{#if confirmEdit}
|
||||
<div
|
||||
|
|
@ -490,90 +502,99 @@
|
|||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
id="sidebar-chat-item"
|
||||
class=" w-full flex justify-between rounded-xl px-2 py-[6px] {id === $chatId || confirmEdit
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.06] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: selected
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis transition"
|
||||
href="/c/{id}"
|
||||
on:click={() => {
|
||||
dispatch('select');
|
||||
|
||||
if ($selectedFolder) {
|
||||
selectedFolder.set(null);
|
||||
}
|
||||
|
||||
if ($mobile) {
|
||||
showSidebar.set(false);
|
||||
}
|
||||
|
||||
// Optimistically mark as read in UI when clicked
|
||||
unread = false;
|
||||
lastReadAt = Date.now() / 1000;
|
||||
}}
|
||||
on:dblclick={async (e) => {
|
||||
if (readonly) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
doubleClicked = true;
|
||||
renameHandler();
|
||||
}}
|
||||
on:mouseenter={(e) => {
|
||||
mouseOver = true;
|
||||
}}
|
||||
on:mouseleave={(e) => {
|
||||
mouseOver = false;
|
||||
}}
|
||||
on:focus={(e) => {}}
|
||||
draggable="false"
|
||||
<LinkPreview.Root
|
||||
openDelay={300}
|
||||
closeDelay={0}
|
||||
disabled={$mobile || confirmEdit || dragged}
|
||||
bind:open={openPreview}
|
||||
>
|
||||
{#if ownerUserId}
|
||||
<Tooltip content={ownerName || 'Unknown'}>
|
||||
<img
|
||||
src="/api/v1/users/{ownerUserId}/profile/image"
|
||||
alt=""
|
||||
class="size-3.5 rounded-full shrink-0 object-cover mr-1.5"
|
||||
/>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<LinkPreview.Trigger
|
||||
id="sidebar-chat-item"
|
||||
class=" w-full flex justify-between rounded-xl px-2 py-[6px] {id === $chatId || confirmEdit
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.06] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: selected
|
||||
? ($settings?.highContrastMode ?? false)
|
||||
? 'bg-black/[0.035] dark:bg-white/[0.055] selected'
|
||||
: 'bg-black/[0.035] dark:bg-white/[0.045] selected'
|
||||
: ' hover:bg-gray-50 dark:hover:bg-gray-900 group-hover:bg-gray-50 dark:group-hover:bg-gray-900'} whitespace-nowrap text-ellipsis transition"
|
||||
href="/c/{id}"
|
||||
onclick={() => {
|
||||
openPreview = false;
|
||||
dispatch('select');
|
||||
|
||||
<!-- Loading spinner for active chat (left side) -->
|
||||
{#if active}
|
||||
<div class="shrink-0 self-center pr-2">
|
||||
<Spinner className="size-3" />
|
||||
</div>
|
||||
{/if}
|
||||
if ($selectedFolder) {
|
||||
selectedFolder.set(null);
|
||||
}
|
||||
|
||||
<div class="flex self-center flex-1 w-full min-w-0">
|
||||
{#if unread}
|
||||
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
|
||||
<div class="size-1.5 bg-sky-500 rounded-full" />
|
||||
if ($mobile) {
|
||||
showSidebar.set(false);
|
||||
}
|
||||
|
||||
// Optimistically mark as read in UI when clicked
|
||||
unread = false;
|
||||
lastReadAt = Date.now() / 1000;
|
||||
}}
|
||||
ondblclick={async (e) => {
|
||||
if (readonly) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
doubleClicked = true;
|
||||
renameHandler();
|
||||
}}
|
||||
draggable="false"
|
||||
>
|
||||
{#if ownerUserId}
|
||||
<Tooltip content={ownerName || 'Unknown'}>
|
||||
<img
|
||||
src="/api/v1/users/{ownerUserId}/profile/image"
|
||||
alt=""
|
||||
class="size-3.5 rounded-full shrink-0 object-cover mr-1.5"
|
||||
/>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<!-- Loading spinner for active chat (left side) -->
|
||||
{#if active}
|
||||
<div class="shrink-0 self-center pr-2">
|
||||
<Spinner className="size-3" />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
dir="auto"
|
||||
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
|
||||
? 'font-normal text-gray-800 dark:text-gray-200'
|
||||
: ''} {showInlineActions && !readonly ? 'pr-12' : ''}"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time ago indicator -->
|
||||
{#if (updatedAt ?? createdAt) && !showInlineActions}
|
||||
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
|
||||
{formatTimeAgo(updatedAt ?? createdAt)}
|
||||
<div class="flex self-center flex-1 w-full min-w-0">
|
||||
{#if unread}
|
||||
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
|
||||
<div class="size-1.5 bg-sky-500 rounded-full"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
dir="auto"
|
||||
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
|
||||
? 'font-normal text-gray-800 dark:text-gray-200'
|
||||
: ''} {showInlineActions && !readonly ? 'pr-12' : ''}"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Time ago indicator -->
|
||||
{#if (updatedAt ?? createdAt) && !showInlineActions}
|
||||
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
|
||||
{formatTimeAgo((updatedAt ?? createdAt) as number)}
|
||||
</div>
|
||||
{/if}
|
||||
</LinkPreview.Trigger>
|
||||
|
||||
<ChatHoverPreview
|
||||
chatId={id}
|
||||
title={chatTitle || title}
|
||||
{openPreview}
|
||||
side="right"
|
||||
align="center"
|
||||
/>
|
||||
</LinkPreview.Root>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@
|
|||
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import FolderModal from './Folders/FolderModal.svelte';
|
||||
import Emoji from '$lib/components/common/Emoji.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
||||
export let folderRegistry = {};
|
||||
export let open = false;
|
||||
|
|
@ -385,24 +384,46 @@
|
|||
}, 500);
|
||||
};
|
||||
|
||||
const SIDEBAR_CHATS_PAGE_SIZE = 10;
|
||||
/** @type {any[] | null} */
|
||||
let chats = null;
|
||||
export const setFolderItems = async () => {
|
||||
let chatsPage = 1;
|
||||
let hasMoreChats = false;
|
||||
let chatsLoading = false;
|
||||
|
||||
export const setFolderItems = async (append = false) => {
|
||||
await tick();
|
||||
if (open) {
|
||||
if (open && !chatsLoading) {
|
||||
// Always use getSharedFolderChats so owners also see chats
|
||||
// created by users who have write access to this folder.
|
||||
const nextPage = append ? chatsPage + 1 : 1;
|
||||
chatsLoading = true;
|
||||
try {
|
||||
const res = await getSharedFolderChats(localStorage.token, folderId);
|
||||
chats = res?.chats ?? [];
|
||||
const res = await getSharedFolderChats(localStorage.token, folderId, {
|
||||
page: nextPage
|
||||
});
|
||||
const nextChats = res?.chats ?? [];
|
||||
chats = append ? [...(chats ?? []), ...nextChats] : nextChats;
|
||||
chatsPage = nextPage;
|
||||
hasMoreChats = res?.has_more ?? nextChats.length === SIDEBAR_CHATS_PAGE_SIZE;
|
||||
} catch (error) {
|
||||
// Fallback to regular API
|
||||
chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return [];
|
||||
});
|
||||
const fallback = await getChatListByFolderId(localStorage.token, folderId, nextPage).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
return [];
|
||||
}
|
||||
);
|
||||
chats = append ? [...(chats ?? []), ...(fallback ?? [])] : (fallback ?? []);
|
||||
chatsPage = nextPage;
|
||||
hasMoreChats = (fallback?.length ?? 0) === SIDEBAR_CHATS_PAGE_SIZE;
|
||||
} finally {
|
||||
chatsLoading = false;
|
||||
}
|
||||
} else {
|
||||
chats = null;
|
||||
chatsPage = 1;
|
||||
hasMoreChats = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -564,7 +585,7 @@
|
|||
await selectedFolder.set({ ...folders[folderId], ...folder });
|
||||
}
|
||||
|
||||
await goto('/');
|
||||
await goto(`/folders/${folderId}`);
|
||||
|
||||
if ($mobile) {
|
||||
showSidebar.set(!$showSidebar);
|
||||
|
|
@ -678,7 +699,7 @@
|
|||
</div>
|
||||
|
||||
<div slot="content" class="w-full">
|
||||
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0}
|
||||
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0 || hasMoreChats}
|
||||
<div
|
||||
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
|
||||
>
|
||||
|
|
@ -731,12 +752,41 @@
|
|||
}}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if hasMoreChats}
|
||||
<button
|
||||
class="w-full px-2 py-0.5 text-left text-[11px] text-gray-400 transition hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-600 dark:hover:text-gray-300"
|
||||
disabled={chatsLoading}
|
||||
on:click={() => setFolderItems(true)}
|
||||
>
|
||||
{#if chatsLoading}
|
||||
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
|
||||
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"
|
||||
></span>
|
||||
<span
|
||||
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
|
||||
></span>
|
||||
<span
|
||||
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
|
||||
></span>
|
||||
</div>
|
||||
{:else}
|
||||
{$i18n.t('Show more')}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if chats === null}
|
||||
<div class="flex justify-center items-center p-2">
|
||||
<Spinner className="size-4 text-gray-500" />
|
||||
{#if chats === null && chatsLoading}
|
||||
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
|
||||
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
|
||||
<span
|
||||
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
|
||||
></span>
|
||||
<span
|
||||
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { getContext, createEventDispatcher } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
const i18n: Writable<any> = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -13,16 +15,18 @@
|
|||
import ChevronRight from './icons/ChevronRight.svelte';
|
||||
import Eye from './icons/Eye.svelte';
|
||||
import FolderIcon from './icons/Folder.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
||||
export let folder: any;
|
||||
export let allSharedFolders: any[] = [];
|
||||
export let className = '';
|
||||
|
||||
const SIDEBAR_CHATS_PAGE_SIZE = 10;
|
||||
let expanded = false;
|
||||
let chats: any[] = [];
|
||||
let loading = false;
|
||||
let loaded = false;
|
||||
let page = 1;
|
||||
let hasMoreChats = false;
|
||||
|
||||
$: children = allSharedFolders.filter((f) => f.parent_id === folder.id);
|
||||
$: permission = folder.permission || 'read';
|
||||
|
|
@ -35,16 +39,27 @@
|
|||
}
|
||||
};
|
||||
|
||||
const loadChats = async () => {
|
||||
const loadChats = async (append = false) => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const res = await getSharedFolderChats(localStorage.token, folder.id);
|
||||
const nextPage = append ? page + 1 : 1;
|
||||
const res = await getSharedFolderChats(localStorage.token, folder.id, {
|
||||
page: nextPage
|
||||
});
|
||||
if (res) {
|
||||
chats = res.chats || [];
|
||||
const nextChats = res.chats || [];
|
||||
chats = append ? [...chats, ...nextChats] : nextChats;
|
||||
page = nextPage;
|
||||
hasMoreChats = res.has_more ?? nextChats.length === SIDEBAR_CHATS_PAGE_SIZE;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load shared folder chats', e);
|
||||
chats = [];
|
||||
hasMoreChats = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
loaded = true;
|
||||
|
|
@ -92,10 +107,15 @@
|
|||
|
||||
{#if expanded}
|
||||
<div class="pl-3">
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-1.5 py-1 px-2 text-xs text-gray-400">
|
||||
<Spinner className="size-3" />
|
||||
<span>{$i18n.t('Loading...')}</span>
|
||||
{#if loading && !loaded}
|
||||
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
|
||||
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
|
||||
<span
|
||||
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
|
||||
></span>
|
||||
<span
|
||||
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
|
||||
></span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each chats as chat (chat.id)}
|
||||
|
|
@ -115,7 +135,29 @@
|
|||
<svelte:self folder={child} {allSharedFolders} />
|
||||
{/each}
|
||||
|
||||
{#if chats.length === 0 && children.length === 0}
|
||||
{#if hasMoreChats}
|
||||
<button
|
||||
class="w-full px-2 py-0.5 text-left text-[11px] text-gray-400 transition hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-600 dark:hover:text-gray-300"
|
||||
disabled={loading}
|
||||
on:click={() => loadChats(true)}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
|
||||
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
|
||||
<span
|
||||
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
|
||||
></span>
|
||||
<span
|
||||
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
|
||||
></span>
|
||||
</div>
|
||||
{:else}
|
||||
{$i18n.t('Show more')}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if chats.length === 0 && children.length === 0 && !hasMoreChats}
|
||||
<div class="text-[11px] text-gray-400 dark:text-gray-600 py-1 px-2">
|
||||
{$i18n.t('Empty')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -148,6 +148,27 @@
|
|||
voices = res?.voices ?? [];
|
||||
};
|
||||
|
||||
const toModelKnowledgeReference = (item: any) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return item;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'description',
|
||||
'context',
|
||||
'legacy',
|
||||
'collection_name',
|
||||
'collection_names'
|
||||
]
|
||||
.filter((key) => item[key] !== undefined && item[key] !== null && item[key] !== '')
|
||||
.map((key) => [key, item[key]])
|
||||
);
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
loading = true;
|
||||
|
||||
|
|
@ -194,7 +215,7 @@
|
|||
}
|
||||
|
||||
if (knowledge.length > 0) {
|
||||
info.meta.knowledge = knowledge;
|
||||
info.meta.knowledge = knowledge.map(toModelKnowledgeReference);
|
||||
} else {
|
||||
if (info.meta.knowledge) {
|
||||
delete info.meta.knowledge;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"{{COUNT}}y_time_ago": "",
|
||||
"{{label}} contains invalid JSON": "",
|
||||
"{{label}} must be a JSON object": "",
|
||||
"{{model}} download has been canceled": "",
|
||||
"{{model}} download has been canceled": "{{model}} download has been cancelled",
|
||||
"{{modelName}} profile image": "",
|
||||
"{{NAMES}} reacted with {{REACTION}}": "",
|
||||
"{{user}}'s Chats": "",
|
||||
|
|
@ -198,8 +198,8 @@
|
|||
"Allow users to create API keys for programmatic access.": "",
|
||||
"Allow users to edit existing images.": "",
|
||||
"Allow users to generate images from prompts.": "",
|
||||
"Allow users to organize chats into folders.": "",
|
||||
"Allow users to save memories for more personalized responses.": "",
|
||||
"Allow users to organize chats into folders.": "Allow users to organise chats into folders.",
|
||||
"Allow users to save memories for more personalized responses.": "Allow users to save memories for more personalised responses.",
|
||||
"Allow users to search the web from chats.": "",
|
||||
"Allow users to share chats with the Open WebUI community.": "",
|
||||
"Allow users to use channels for shared conversations.": "",
|
||||
|
|
@ -377,7 +377,7 @@
|
|||
"Button Prompt": "",
|
||||
"by {{name}}": "",
|
||||
"By {{name}}": "",
|
||||
"By supporting the project through sponsorship or an enterprise license, you help us stay independent, ship new features faster, improve stability, and grow Open WebUI for the long haul.": "",
|
||||
"By supporting the project through sponsorship or an enterprise license, you help us stay independent, ship new features faster, improve stability, and grow Open WebUI for the long haul.": "By supporting the project through sponsorship or an enterprise licence, you help us stay independent, ship new features faster, improve stability, and grow Open WebUI for the long haul.",
|
||||
"Bypass Embedding and Retrieval": "",
|
||||
"Bypass Web Loader": "",
|
||||
"Cache Base Model List": "",
|
||||
|
|
@ -444,7 +444,7 @@
|
|||
"Choose how uploaded documents are parsed before indexing.": "",
|
||||
"Choose how web result pages are fetched and read.": "",
|
||||
"Choose the browser voice used for speech output.": "",
|
||||
"Choose the color theme used by the interface.": "",
|
||||
"Choose the color theme used by the interface.": "Choose the colour theme used by the interface.",
|
||||
"Choose the configured text-to-speech service voice.": "",
|
||||
"Choose the engine used to read assistant responses aloud.": "",
|
||||
"Choose the engine used to transcribe voice input.": "",
|
||||
|
|
@ -676,7 +676,7 @@
|
|||
"Current Password": "",
|
||||
"Current streak": "",
|
||||
"Custom": "",
|
||||
"Custom color": "",
|
||||
"Custom color": "Custom colour",
|
||||
"Custom description enabled": "",
|
||||
"Custom Gender": "",
|
||||
"Custom message shown while an account waits for approval.": "",
|
||||
|
|
@ -835,7 +835,7 @@
|
|||
"Download all users as CSV.": "",
|
||||
"Download as JSON": "",
|
||||
"Download as SVG": "",
|
||||
"Download canceled": "",
|
||||
"Download canceled": "Download cancelled",
|
||||
"Download every user's chat history as JSON.": "",
|
||||
"Download the application database when supported.": "",
|
||||
"Download the current admin configuration as JSON.": "",
|
||||
|
|
@ -914,7 +914,7 @@
|
|||
"Enable Mirostat sampling for controlling perplexity.": "",
|
||||
"Enable sub-agents": "",
|
||||
"Enable Web Search": "",
|
||||
"Enable, disable, or customize the reasoning tags used by the model. \"Enabled\" uses default tags, \"Disabled\" turns off reasoning tags, and \"Custom\" lets you specify your own start and end tags.": "",
|
||||
"Enable, disable, or customize the reasoning tags used by the model. \"Enabled\" uses default tags, \"Disabled\" turns off reasoning tags, and \"Custom\" lets you specify your own start and end tags.": "Enable, disable, or customise the reasoning tags used by the model. \"Enabled\" uses default tags, \"Disabled\" turns off reasoning tags, and \"Custom\" lets you specify your own start and end tags.",
|
||||
"Enabled": "",
|
||||
"End Tag": "",
|
||||
"Endpoint": "",
|
||||
|
|
@ -971,7 +971,7 @@
|
|||
"Enter GitHub Raw URL": "",
|
||||
"Enter Google PSE API Key": "",
|
||||
"Enter Google PSE Engine Id": "",
|
||||
"Enter hex color (e.g. #FF0000)": "",
|
||||
"Enter hex color (e.g. #FF0000)": "Enter hex colour (e.g. #FF0000)",
|
||||
"Enter Image Size (e.g. 512x512)": "",
|
||||
"Enter Jina API Base URL": "",
|
||||
"Enter Jina API Key": "",
|
||||
|
|
@ -1034,7 +1034,7 @@
|
|||
"Enter Tika Server URL": "",
|
||||
"Enter to Send": "",
|
||||
"Enter token threshold": "",
|
||||
"Enter Tokenizer Model": "",
|
||||
"Enter Tokenizer Model": "Enter Tokeniser Model",
|
||||
"Enter Top K": "",
|
||||
"Enter Top K Reranker": "",
|
||||
"Enter URL (e.g. http://127.0.0.1:7860/)": "",
|
||||
|
|
@ -1059,7 +1059,7 @@
|
|||
"Enter Your Password": "",
|
||||
"Enter Your Role": "",
|
||||
"Enter Your Username": "",
|
||||
"Enterprise licenses also include dedicated support, customization options, and more, at a fraction of the cost of building and maintaining this stack internally.": "",
|
||||
"Enterprise licenses also include dedicated support, customization options, and more, at a fraction of the cost of building and maintaining this stack internally.": "Enterprise licences also include dedicated support, customisation options, and more, at a fraction of the cost of building and maintaining this stack internally.",
|
||||
"Enterprise licensing": "",
|
||||
"Entra ID": "",
|
||||
"Environment Variables": "",
|
||||
|
|
@ -1095,7 +1095,7 @@
|
|||
"Example: mail": "",
|
||||
"Example: ou=users,dc=foo,dc=example": "",
|
||||
"Example: sAMAccountName or uid or userPrincipalName": "",
|
||||
"Exceeded the number of seats in your license. Please contact support to increase the number of seats.": "",
|
||||
"Exceeded the number of seats in your license. Please contact support to increase the number of seats.": "Exceeded the number of seats in your licence. Please contact support to increase the number of seats.",
|
||||
"Exclude": "",
|
||||
"Execute code": "",
|
||||
"Execute code for analysis": "",
|
||||
|
|
@ -1173,7 +1173,7 @@
|
|||
"Failed to refresh terminals: {{error}}": "",
|
||||
"Failed to remove member": "",
|
||||
"Failed to render diagram": "",
|
||||
"Failed to render visualization": "",
|
||||
"Failed to render visualization": "Failed to render visualisation",
|
||||
"Failed to save connections": "",
|
||||
"Failed to save conversation": "",
|
||||
"Failed to save models configuration": "",
|
||||
|
|
@ -1523,7 +1523,7 @@
|
|||
"Let speech interrupt the assistant during a voice call.": "",
|
||||
"Let users rate assistant responses.": "",
|
||||
"lexical": "",
|
||||
"License": "",
|
||||
"License": "Licence",
|
||||
"Lifecycle JSON": "",
|
||||
"Lifetime tokens": "",
|
||||
"Lift List": "",
|
||||
|
|
@ -1859,7 +1859,7 @@
|
|||
"October": "",
|
||||
"Off": "",
|
||||
"Okay, Let's Go!": "",
|
||||
"Older messages are summarized when estimated context exceeds this token limit.": "",
|
||||
"Older messages are summarized when estimated context exceeds this token limit.": "Older messages are summarised when estimated context exceeds this token limit.",
|
||||
"OLED Dark": "",
|
||||
"Ollama": "",
|
||||
"Ollama API": "",
|
||||
|
|
@ -2502,8 +2502,8 @@
|
|||
"Suggest completions while users type chat messages.": "",
|
||||
"Suggested": "",
|
||||
"Suggested prompts": "",
|
||||
"Summarize older chat history when the conversation context grows large.": "",
|
||||
"Summarize this note.": "",
|
||||
"Summarize older chat history when the conversation context grows large.": "Summarise older chat history when the conversation context grows large.",
|
||||
"Summarize this note.": "Summarise this note.",
|
||||
"Support": "",
|
||||
"Support this plugin:": "",
|
||||
"Supported MIME Types": "",
|
||||
|
|
@ -2652,8 +2652,8 @@
|
|||
"Token Cap": "",
|
||||
"Token counts are estimates and may not reflect actual API usage": "",
|
||||
"Token Threshold": "",
|
||||
"Tokenizer Model": "",
|
||||
"Tokenizer model used for transformer token splitting.": "",
|
||||
"Tokenizer Model": "Tokeniser Model",
|
||||
"Tokenizer model used for transformer token splitting.": "Tokeniser model used for transformer token splitting.",
|
||||
"tokens": "",
|
||||
"Tokens": "",
|
||||
"Too verbose": "",
|
||||
|
|
@ -2760,7 +2760,7 @@
|
|||
"Use Chat Completions API": "",
|
||||
"Use device vibration feedback on supported Android devices.": "",
|
||||
"Use discovered scopes": "",
|
||||
"Use groups to organize your users and assign permissions.": "",
|
||||
"Use groups to organize your users and assign permissions.": "Use groups to organise your users and assign permissions.",
|
||||
"Use LLM": "",
|
||||
"Use proxy environment variables to fetch page contents.": "",
|
||||
"Use search results without fetching page contents.": "",
|
||||
|
|
@ -2894,7 +2894,7 @@
|
|||
"Yesterday": "",
|
||||
"You": "",
|
||||
"You are a sub-agent...": "",
|
||||
"You are currently using a trial license. Please contact support to upgrade your license.": "",
|
||||
"You are currently using a trial license. Please contact support to upgrade your license.": "You are currently using a trial licence. Please contact support to upgrade your licence.",
|
||||
"You can only chat with a maximum of {{maxCount}} file(s) at a time.": "",
|
||||
"You can personalize your interactions with LLMs by adding memories through the 'Manage' button below, making them more helpful and tailored to you.": "You can personalise your interactions with LLMs by adding memories through the 'Manage' button below, making them more helpful and tailored to you.",
|
||||
"You cannot upload an empty file.": "",
|
||||
|
|
@ -2908,7 +2908,7 @@
|
|||
"You do not have permission to upload files to this knowledge base.": "",
|
||||
"You do not have permission to upload files.": "",
|
||||
"You do not have permission to upload web content.": "",
|
||||
"You have more than 50 users, which often means this workspace is supporting organizational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.": "",
|
||||
"You have more than 50 users, which often means this workspace is supporting organizational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.": "You have more than 50 users, which often means this workspace is supporting organisational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.",
|
||||
"You have no archived conversations.": "",
|
||||
"You have no shared conversations.": "",
|
||||
"You have shared this chat": "",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -203,25 +203,28 @@ export const convertMessagesToHistory = (messages) => {
|
|||
let messageId = null;
|
||||
|
||||
for (const message of messages) {
|
||||
messageId = uuidv4();
|
||||
|
||||
if (parentMessageId !== null) {
|
||||
history.messages[parentMessageId].childrenIds = [
|
||||
...history.messages[parentMessageId].childrenIds,
|
||||
messageId
|
||||
];
|
||||
}
|
||||
messageId = message?.id ?? uuidv4();
|
||||
const parentId = message?.parentId ?? parentMessageId;
|
||||
|
||||
history.messages[messageId] = {
|
||||
...message,
|
||||
id: messageId,
|
||||
parentId: parentMessageId,
|
||||
parentId,
|
||||
childrenIds: []
|
||||
};
|
||||
|
||||
parentMessageId = messageId;
|
||||
}
|
||||
|
||||
for (const message of Object.values(history.messages)) {
|
||||
if (message.parentId && history.messages[message.parentId]) {
|
||||
history.messages[message.parentId].childrenIds = [
|
||||
...history.messages[message.parentId].childrenIds,
|
||||
message.id
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
history.currentId = messageId;
|
||||
return history;
|
||||
};
|
||||
|
|
|
|||
46
src/routes/(app)/folders/[folderId]/+page.svelte
Normal file
46
src/routes/(app)/folders/[folderId]/+page.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Chat from '$lib/components/chat/Chat.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import { getFolderById } from '$lib/apis/folders';
|
||||
import { selectedFolder } from '$lib/stores';
|
||||
|
||||
let ready = false;
|
||||
|
||||
onMount(async () => {
|
||||
const folderId = $page.params.folderId;
|
||||
if (!folderId) {
|
||||
await goto('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const folder = await getFolderById(localStorage.token, folderId).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
await goto('/');
|
||||
return;
|
||||
}
|
||||
|
||||
await selectedFolder.set(folder);
|
||||
ready = true;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
selectedFolder.set(null);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if ready}
|
||||
<Chat />
|
||||
{:else}
|
||||
<div class="w-full h-screen flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
38
uv.lock
generated
38
uv.lock
generated
|
|
@ -191,15 +191,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "appdirs"
|
||||
version = "1.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "apscheduler"
|
||||
version = "3.11.2"
|
||||
|
|
@ -1375,20 +1366,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs"
|
||||
version = "2.4.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "appdirs" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/a9/af5bfd5a92592c16cdae5c04f68187a309be8a146b528eac3c6e30edbad2/fs-2.4.16.tar.gz", hash = "sha256:ae97c7d51213f4b70b6a958292530289090de3a7e15841e108fbe144f069d313", size = 187441 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/5c/a3d95dc1ec6cdeb032d789b552ecc76effa3557ea9186e1566df6aac18df/fs-2.4.16-py2.py3-none-any.whl", hash = "sha256:660064febbccda264ae0b6bace80a8d1be9e089e0a5eb2427b7d517f9a91545c", size = 135261 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsspec"
|
||||
version = "2025.3.0"
|
||||
|
|
@ -1415,19 +1392,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gcp-storage-emulator"
|
||||
version = "2024.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fs" },
|
||||
{ name = "google-crc32c" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/c2/a0b0e1e54fdd9453603d90939faf652e2488b617c8752edc4ebcd89f1686/gcp_storage_emulator-2024.8.3.tar.gz", hash = "sha256:e5d45e5c23a0344c1c4c44b8f8c36f7e8975ca1fcc5134cab608b96ddccd9225", size = 24928 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/bf/b6c717bd7a5b59244388057d36789e72c28bfaa5e51f3a494563a4e3028e/gcp_storage_emulator-2024.8.3-py3-none-any.whl", hash = "sha256:1dc4ea56a0caf50fc6092898b9461d08b494824bfbbcca168e2af5da89c053ce", size = 19385 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gitdb"
|
||||
version = "4.0.12"
|
||||
|
|
@ -3070,7 +3034,6 @@ all = [
|
|||
{ name = "colbert-ai" },
|
||||
{ name = "docker" },
|
||||
{ name = "elasticsearch" },
|
||||
{ name = "gcp-storage-emulator" },
|
||||
{ name = "moto", extra = ["s3"] },
|
||||
{ name = "oracledb" },
|
||||
{ name = "pgvector" },
|
||||
|
|
@ -3140,7 +3103,6 @@ requires-dist = [
|
|||
{ name = "faster-whisper", specifier = "==1.2.1" },
|
||||
{ name = "fpdf2", specifier = "==2.8.7" },
|
||||
{ name = "ftfy", specifier = "==6.3.1" },
|
||||
{ name = "gcp-storage-emulator", marker = "extra == 'all'", specifier = ">=2024.8.3" },
|
||||
{ name = "google-api-python-client", specifier = "==2.197.0" },
|
||||
{ name = "google-auth-httplib2", specifier = "==0.4.0" },
|
||||
{ name = "google-auth-oauthlib", specifier = "==1.4.0" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue