Merge pull request #1631 from open-webui/dev

0.1.120
This commit is contained in:
Timothy Jaeryang Baek 2024-04-20 17:41:00 -07:00 committed by GitHub
commit 22c50f62cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 1462 additions and 242 deletions

View file

@ -24,6 +24,9 @@ assignees: ''
## Environment
- **Open WebUI Version:** [e.g., 0.1.120]
- **Ollama (if applicable):** [e.g., 0.1.30, 0.1.32-rc1]
- **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04]
- **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0]

View file

@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.120] - 2024-04-20
### Added
- **📦 Archive Chat Feature**: Easily archive chats with a new sidebar button, and access archived chats via the profile button > archived chats.
- **🔊 Configurable Text-to-Speech Endpoint**: Customize your Text-to-Speech experience with configurable OpenAI endpoints.
- **🛠️ Improved Error Handling**: Enhanced error message handling for connection failures.
- **⌨️ Enhanced Shortcut**: When editing messages, use ctrl/cmd+enter to save and submit, and esc to close.
- **🌐 Language Support**: Added support for Georgian and enhanced translations for Portuguese and Vietnamese.
### Fixed
- **🔧 Model Selector**: Resolved issue where default model selection was not saving.
- **🔗 Share Link Copy Button**: Fixed bug where the copy button wasn't copying links in Safari.
- **🎨 Light Theme Styling**: Addressed styling issue with the light theme.
## [0.1.119] - 2024-04-16
### Added

View file

@ -185,4 +185,4 @@ If you have any questions, suggestions, or need assistance, please open an issue
---
Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open Web UI even more amazing together! 💪
Created by [Timothy J. Baek](https://github.com/tjbck) - Let's make Open WebUI even more amazing together! 💪

View file

@ -10,8 +10,19 @@ from fastapi import (
File,
Form,
)
from fastapi.responses import StreamingResponse, JSONResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from faster_whisper import WhisperModel
from pydantic import BaseModel
import requests
import hashlib
from pathlib import Path
import json
from constants import ERROR_MESSAGES
from utils.utils import (
@ -30,6 +41,8 @@ from config import (
WHISPER_MODEL_DIR,
WHISPER_MODEL_AUTO_UPDATE,
DEVICE_TYPE,
AUDIO_OPENAI_API_BASE_URL,
AUDIO_OPENAI_API_KEY,
)
log = logging.getLogger(__name__)
@ -44,12 +57,104 @@ app.add_middleware(
allow_headers=["*"],
)
app.state.OPENAI_API_BASE_URL = AUDIO_OPENAI_API_BASE_URL
app.state.OPENAI_API_KEY = AUDIO_OPENAI_API_KEY
# setting device type for whisper model
whisper_device_type = DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu"
log.info(f"whisper_device_type: {whisper_device_type}")
SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/")
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@app.post("/transcribe")
class OpenAIConfigUpdateForm(BaseModel):
url: str
key: str
@app.get("/config")
async def get_openai_config(user=Depends(get_admin_user)):
return {
"OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL,
"OPENAI_API_KEY": app.state.OPENAI_API_KEY,
}
@app.post("/config/update")
async def update_openai_config(
form_data: OpenAIConfigUpdateForm, user=Depends(get_admin_user)
):
if form_data.key == "":
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
app.state.OPENAI_API_BASE_URL = form_data.url
app.state.OPENAI_API_KEY = form_data.key
return {
"status": True,
"OPENAI_API_BASE_URL": app.state.OPENAI_API_BASE_URL,
"OPENAI_API_KEY": app.state.OPENAI_API_KEY,
}
@app.post("/speech")
async def speech(request: Request, user=Depends(get_verified_user)):
body = await request.body()
name = hashlib.sha256(body).hexdigest()
file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
# Check if the file already exists in the cache
if file_path.is_file():
return FileResponse(file_path)
headers = {}
headers["Authorization"] = f"Bearer {app.state.OPENAI_API_KEY}"
headers["Content-Type"] = "application/json"
r = None
try:
r = requests.post(
url=f"{app.state.OPENAI_API_BASE_URL}/audio/speech",
data=body,
headers=headers,
stream=True,
)
r.raise_for_status()
# Save the streaming content to a file
with open(file_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
with open(file_body_path, "w") as f:
json.dump(json.loads(body.decode("utf-8")), f)
# Return the saved file
return FileResponse(file_path)
except Exception as e:
log.exception(e)
error_detail = "Open WebUI: Server Connection Error"
if r is not None:
try:
res = r.json()
if "error" in res:
error_detail = f"External: {res['error']['message']}"
except:
error_detail = f"External: {e}"
raise HTTPException(
status_code=r.status_code if r != None else 500,
detail=error_detail,
)
@app.post("/transcriptions")
def transcribe(
file: UploadFile = File(...),
user=Depends(get_current_user),

View file

@ -35,6 +35,8 @@ from config import (
ENABLE_IMAGE_GENERATION,
AUTOMATIC1111_BASE_URL,
COMFYUI_BASE_URL,
OPENAI_API_BASE_URL,
OPENAI_API_KEY,
)
@ -56,7 +58,9 @@ app.add_middleware(
app.state.ENGINE = ""
app.state.ENABLED = ENABLE_IMAGE_GENERATION
app.state.OPENAI_API_KEY = ""
app.state.OPENAI_API_BASE_URL = OPENAI_API_BASE_URL
app.state.OPENAI_API_KEY = OPENAI_API_KEY
app.state.MODEL = ""
@ -360,7 +364,7 @@ def generate_image(
}
r = requests.post(
url=f"https://api.openai.com/v1/images/generations",
url=f"{app.state.OPENAI_API_BASE_URL}/images/generations",
json=data,
headers=headers,
)

View file

@ -341,7 +341,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
try:
res = r.json()
if "error" in res:
error_detail = f"External: {res['error']}"
error_detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
except:
error_detail = f"External: {e}"

View file

@ -70,6 +70,8 @@ from config import (
RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
RAG_OPENAI_API_BASE_URL,
RAG_OPENAI_API_KEY,
DEVICE_TYPE,
CHROMA_CLIENT,
CHUNK_SIZE,
@ -94,8 +96,8 @@ app.state.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
app.state.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
app.state.RAG_TEMPLATE = RAG_TEMPLATE
app.state.RAG_OPENAI_API_BASE_URL = "https://api.openai.com"
app.state.RAG_OPENAI_API_KEY = ""
app.state.OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL
app.state.OPENAI_API_KEY = RAG_OPENAI_API_KEY
app.state.PDF_EXTRACT_IMAGES = False
@ -148,8 +150,8 @@ async def get_embedding_config(user=Depends(get_admin_user)):
"embedding_engine": app.state.RAG_EMBEDDING_ENGINE,
"embedding_model": app.state.RAG_EMBEDDING_MODEL,
"openai_config": {
"url": app.state.RAG_OPENAI_API_BASE_URL,
"key": app.state.RAG_OPENAI_API_KEY,
"url": app.state.OPENAI_API_BASE_URL,
"key": app.state.OPENAI_API_KEY,
},
}
@ -180,8 +182,8 @@ async def update_embedding_config(
app.state.sentence_transformer_ef = None
if form_data.openai_config != None:
app.state.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url
app.state.RAG_OPENAI_API_KEY = form_data.openai_config.key
app.state.OPENAI_API_BASE_URL = form_data.openai_config.url
app.state.OPENAI_API_KEY = form_data.openai_config.key
else:
sentence_transformer_ef = (
embedding_functions.SentenceTransformerEmbeddingFunction(
@ -199,8 +201,8 @@ async def update_embedding_config(
"embedding_engine": app.state.RAG_EMBEDDING_ENGINE,
"embedding_model": app.state.RAG_EMBEDDING_MODEL,
"openai_config": {
"url": app.state.RAG_OPENAI_API_BASE_URL,
"key": app.state.RAG_OPENAI_API_KEY,
"url": app.state.OPENAI_API_BASE_URL,
"key": app.state.OPENAI_API_KEY,
},
}
@ -315,8 +317,8 @@ def query_doc_handler(
query_embeddings = generate_openai_embeddings(
model=app.state.RAG_EMBEDDING_MODEL,
text=form_data.query,
key=app.state.RAG_OPENAI_API_KEY,
url=app.state.RAG_OPENAI_API_BASE_URL,
key=app.state.OPENAI_API_KEY,
url=app.state.OPENAI_API_BASE_URL,
)
return query_embeddings_doc(
@ -367,8 +369,8 @@ def query_collection_handler(
query_embeddings = generate_openai_embeddings(
model=app.state.RAG_EMBEDDING_MODEL,
text=form_data.query,
key=app.state.RAG_OPENAI_API_KEY,
url=app.state.RAG_OPENAI_API_BASE_URL,
key=app.state.OPENAI_API_KEY,
url=app.state.OPENAI_API_BASE_URL,
)
return query_embeddings_collection(
@ -484,8 +486,8 @@ def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> b
generate_openai_embeddings(
model=app.state.RAG_EMBEDDING_MODEL,
text=text,
key=app.state.RAG_OPENAI_API_KEY,
url=app.state.RAG_OPENAI_API_BASE_URL,
key=app.state.OPENAI_API_KEY,
url=app.state.OPENAI_API_BASE_URL,
)
for text in texts
]

View file

@ -324,11 +324,11 @@ def get_embedding_model_path(
def generate_openai_embeddings(
model: str, text: str, key: str, url: str = "https://api.openai.com"
model: str, text: str, key: str, url: str = "https://api.openai.com/v1"
):
try:
r = requests.post(
f"{url}/v1/embeddings",
f"{url}/embeddings",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",

View file

@ -0,0 +1,46 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields("chat", archived=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields("chat", "archived")

View file

@ -0,0 +1,77 @@
"""Peewee migrations -- 002_add_local_sharing.py.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
"chat",
created_at=pw.DateTimeField(null=True), # Allow null for transition
updated_at=pw.DateTimeField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql(
"UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL"
)
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields("chat", "timestamp")
# Update the fields to be not null now that they are populated
migrator.change_fields(
"chat",
created_at=pw.DateTimeField(null=False),
updated_at=pw.DateTimeField(null=False),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields("chat", timestamp=pw.DateTimeField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql("UPDATE chat SET timestamp = created_at")
# Remove the created_at and updated_at fields
migrator.remove_fields("chat", "created_at", "updated_at")
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields("chat", timestamp=pw.DateTimeField(null=False))

View file

@ -19,8 +19,12 @@ class Chat(Model):
user_id = CharField()
title = CharField()
chat = TextField() # Save Chat JSON as Text
timestamp = DateField()
created_at = DateTimeField()
updated_at = DateTimeField()
share_id = CharField(null=True, unique=True)
archived = BooleanField(default=False)
class Meta:
database = DB
@ -31,8 +35,12 @@ class ChatModel(BaseModel):
user_id: str
title: str
chat: str
timestamp: int # timestamp in epoch
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
share_id: Optional[str] = None
archived: bool = False
####################
@ -53,13 +61,17 @@ class ChatResponse(BaseModel):
user_id: str
title: str
chat: dict
timestamp: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
share_id: Optional[str] = None # id of the chat to be shared
archived: bool
class ChatTitleIdResponse(BaseModel):
id: str
title: str
updated_at: int
created_at: int
class ChatTable:
@ -77,7 +89,8 @@ class ChatTable:
form_data.chat["title"] if "title" in form_data.chat else "New Chat"
),
"chat": json.dumps(form_data.chat),
"timestamp": int(time.time()),
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
@ -89,7 +102,7 @@ class ChatTable:
query = Chat.update(
chat=json.dumps(chat),
title=chat["title"] if "title" in chat else "New Chat",
timestamp=int(time.time()),
updated_at=int(time.time()),
).where(Chat.id == id)
query.execute()
@ -111,7 +124,8 @@ class ChatTable:
"user_id": f"shared-{chat_id}",
"title": chat.title,
"chat": chat.chat,
"timestamp": int(time.time()),
"created_at": chat.created_at,
"updated_at": int(time.time()),
}
)
shared_result = Chat.create(**shared_chat.model_dump())
@ -163,14 +177,42 @@ class ChatTable:
except:
return None
def toggle_chat_archive_by_id(self, id: str) -> Optional[ChatModel]:
try:
chat = self.get_chat_by_id(id)
query = Chat.update(
archived=(not chat.archived),
).where(Chat.id == id)
query.execute()
chat = Chat.get(Chat.id == id)
return ChatModel(**model_to_dict(chat))
except:
return None
def get_archived_chat_lists_by_user_id(
self, user_id: str, skip: int = 0, limit: int = 50
) -> List[ChatModel]:
return [
ChatModel(**model_to_dict(chat))
for chat in Chat.select()
.where(Chat.archived == True)
.where(Chat.user_id == user_id)
.order_by(Chat.updated_at.desc())
# .limit(limit)
# .offset(skip)
]
def get_chat_lists_by_user_id(
self, user_id: str, skip: int = 0, limit: int = 50
) -> List[ChatModel]:
return [
ChatModel(**model_to_dict(chat))
for chat in Chat.select()
.where(Chat.archived == False)
.where(Chat.user_id == user_id)
.order_by(Chat.timestamp.desc())
.order_by(Chat.updated_at.desc())
# .limit(limit)
# .offset(skip)
]
@ -181,14 +223,15 @@ class ChatTable:
return [
ChatModel(**model_to_dict(chat))
for chat in Chat.select()
.where(Chat.archived == False)
.where(Chat.id.in_(chat_ids))
.order_by(Chat.timestamp.desc())
.order_by(Chat.updated_at.desc())
]
def get_all_chats(self) -> List[ChatModel]:
return [
ChatModel(**model_to_dict(chat))
for chat in Chat.select().order_by(Chat.timestamp.desc())
for chat in Chat.select().order_by(Chat.updated_at.desc())
]
def get_all_chats_by_user_id(self, user_id: str) -> List[ChatModel]:
@ -196,7 +239,7 @@ class ChatTable:
ChatModel(**model_to_dict(chat))
for chat in Chat.select()
.where(Chat.user_id == user_id)
.order_by(Chat.timestamp.desc())
.order_by(Chat.updated_at.desc())
]
def get_chat_by_id(self, id: str) -> Optional[ChatModel]:

View file

@ -47,6 +47,18 @@ async def get_user_chats(
return Chats.get_chat_lists_by_user_id(user.id, skip, limit)
############################
# GetArchivedChats
############################
@router.get("/archived", response_model=List[ChatTitleIdResponse])
async def get_archived_user_chats(
user=Depends(get_current_user), skip: int = 0, limit: int = 50
):
return Chats.get_archived_chat_lists_by_user_id(user.id, skip, limit)
############################
# GetAllChats
############################
@ -189,6 +201,23 @@ async def delete_chat_by_id(request: Request, id: str, user=Depends(get_current_
return result
############################
# ArchiveChat
############################
@router.get("/{id}/archive", response_model=Optional[ChatResponse])
async def archive_chat_by_id(id: str, user=Depends(get_current_user)):
chat = Chats.get_chat_by_id_and_user_id(id, user.id)
if chat:
chat = Chats.toggle_chat_archive_by_id(id)
return ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)})
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# ShareChatById
############################

View file

@ -321,6 +321,13 @@ OPENAI_API_BASE_URLS = [
for url in OPENAI_API_BASE_URLS.split(";")
]
OPENAI_API_KEY = ""
OPENAI_API_KEY = OPENAI_API_KEYS[
OPENAI_API_BASE_URLS.index("https://api.openai.com/v1")
]
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
####################################
# WEBUI
####################################
@ -447,6 +454,9 @@ And answer according to the language of the user's question.
Given the context information, answer the query.
Query: [query]"""
RAG_OPENAI_API_BASE_URL = os.getenv("RAG_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL)
RAG_OPENAI_API_KEY = os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY)
####################################
# Transcribe
####################################
@ -467,3 +477,11 @@ ENABLE_IMAGE_GENERATION = (
)
AUTOMATIC1111_BASE_URL = os.getenv("AUTOMATIC1111_BASE_URL", "")
COMFYUI_BASE_URL = os.getenv("COMFYUI_BASE_URL", "")
####################################
# Audio
####################################
AUDIO_OPENAI_API_BASE_URL = os.getenv("AUDIO_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL)
AUDIO_OPENAI_API_KEY = os.getenv("AUDIO_OPENAI_API_KEY", OPENAI_API_KEY)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.1.119",
"version": "0.1.120",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.1.119",
"version": "0.1.120",
"dependencies": {
"@sveltejs/adapter-node": "^1.3.1",
"async": "^3.2.5",

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.1.119",
"version": "0.1.120",
"private": true,
"scripts": {
"dev": "vite dev --host",

View file

@ -1,11 +1,73 @@
import { AUDIO_API_BASE_URL } from '$lib/constants';
export const getAudioConfig = async (token: string) => {
let error = null;
const res = await fetch(`${AUDIO_API_BASE_URL}/config`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
type OpenAIConfigForm = {
url: string;
key: string;
};
export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm) => {
let error = null;
const res = await fetch(`${AUDIO_API_BASE_URL}/config/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...payload
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const transcribeAudio = async (token: string, file: File) => {
const data = new FormData();
data.append('file', file);
let error = null;
const res = await fetch(`${AUDIO_API_BASE_URL}/transcribe`, {
const res = await fetch(`${AUDIO_API_BASE_URL}/transcriptions`, {
method: 'POST',
headers: {
Accept: 'application/json',
@ -29,3 +91,40 @@ export const transcribeAudio = async (token: string, file: File) => {
return res;
};
export const synthesizeOpenAISpeech = async (
token: string = '',
speaker: string = 'alloy',
text: string = ''
) => {
let error = null;
const res = await fetch(`${AUDIO_API_BASE_URL}/speech`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'tts-1',
input: text,
voice: speaker
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -62,6 +62,37 @@ export const getChatList = async (token: string = '') => {
return res;
};
export const getArchivedChatList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAllChats = async (token: string) => {
let error = null;
@ -282,6 +313,38 @@ export const shareChatById = async (token: string, id: string) => {
return res;
};
export const archiveChatById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/archive`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteSharedChatById = async (token: string, id: string) => {
let error = null;

View file

@ -328,10 +328,10 @@
];
};
const inputFiles = e.dataTransfer?.files;
const inputFiles = Array.from(e.dataTransfer?.files);
if (inputFiles && inputFiles.length > 0) {
const file = inputFiles[0];
inputFiles.forEach((file) => {
console.log(file, file.name.split('.').at(-1));
if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) {
reader.readAsDataURL(file);
@ -349,6 +349,7 @@
);
uploadDoc(file);
}
});
} else {
toast.error($i18n.t(`File not found.`));
}
@ -467,6 +468,7 @@
bind:files={inputFiles}
type="file"
hidden
multiple
on:change={async () => {
let reader = new FileReader();
reader.onload = (event) => {
@ -482,7 +484,8 @@
};
if (inputFiles && inputFiles.length > 0) {
const file = inputFiles[0];
const _inputFiles = Array.from(inputFiles);
_inputFiles.forEach((file) => {
if (['image/gif', 'image/jpeg', 'image/png'].includes(file['type'])) {
reader.readAsDataURL(file);
} else if (
@ -501,6 +504,7 @@
uploadDoc(file);
filesInputElement.value = '';
}
});
} else {
toast.error($i18n.t(`File not found.`));
}

View file

@ -12,6 +12,7 @@
import Placeholder from './Messages/Placeholder.svelte';
import Spinner from '../common/Spinner.svelte';
import { imageGenerations } from '$lib/apis/images';
import { copyToClipboard } from '$lib/utils';
const i18n = getContext('i18n');
@ -42,40 +43,11 @@
element.scrollTop = element.scrollHeight;
};
const copyToClipboard = (text) => {
if (!navigator.clipboard) {
var textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
var msg = successful ? 'successful' : 'unsuccessful';
console.log('Fallback: Copying text command was ' + msg);
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
return;
}
navigator.clipboard.writeText(text).then(
function () {
console.log('Async: Copying to clipboard was successful!');
const copyToClipboardWithToast = async (text) => {
const res = await copyToClipboard(text);
if (res) {
toast.success($i18n.t('Copying to clipboard was successful!'));
},
function (err) {
console.error('Async: Could not copy text: ', err);
}
);
};
const confirmEditMessage = async (messageId, content) => {
@ -330,7 +302,7 @@
{confirmEditMessage}
{showPreviousMessage}
{showNextMessage}
{copyToClipboard}
copyToClipboard={copyToClipboardWithToast}
/>
{:else}
<ResponseMessage
@ -344,7 +316,7 @@
{showPreviousMessage}
{showNextMessage}
{rateMessage}
{copyToClipboard}
copyToClipboard={copyToClipboardWithToast}
{continueGeneration}
{regenerateResponse}
on:save={async (e) => {

View file

@ -1,31 +1,39 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onMount } from 'svelte';
import { createEventDispatcher, onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let messageId = null;
export let show = false;
export let message;
const LIKE_REASONS = [
`Accurate information`,
`Followed instructions perfectly`,
`Showcased creativity`,
`Positive attitude`,
`Attention to detail`,
`Thorough explanation`,
`Other`
let LIKE_REASONS = [];
let DISLIKE_REASONS = [];
function loadReasons() {
LIKE_REASONS = [
$i18n.t('Accurate information'),
$i18n.t('Followed instructions perfectly'),
$i18n.t('Showcased creativity'),
$i18n.t('Positive attitude'),
$i18n.t('Attention to detail'),
$i18n.t('Thorough explanation'),
$i18n.t('Other')
];
const DISLIKE_REASONS = [
`Don't like the style`,
`Not factually correct`,
`Didn't fully follow instructions`,
`Refused when it shouldn't have`,
`Being Lazy`,
`Other`
DISLIKE_REASONS = [
$i18n.t("Don't like the style"),
$i18n.t('Not factually correct'),
$i18n.t("Didn't fully follow instructions"),
$i18n.t("Refused when it shouldn't have"),
$i18n.t('Being lazy'),
$i18n.t('Other')
];
}
let reasons = [];
let selectedReason = null;
@ -40,6 +48,7 @@
onMount(() => {
selectedReason = message.annotation.reason;
comment = message.annotation.comment;
loadReasons();
});
const submitHandler = () => {
@ -50,14 +59,17 @@
dispatch('submit');
toast.success('Thanks for your feedback!');
toast.success($i18n.t('Thanks for your feedback!'));
show = false;
};
</script>
<div class=" my-2.5 rounded-xl px-4 py-3 border dark:border-gray-850">
<div
class=" my-2.5 rounded-xl px-4 py-3 border dark:border-gray-850"
id="message-feedback-{messageId}"
>
<div class="flex justify-between items-center">
<div class=" text-sm">Tell us more:</div>
<div class=" text-sm">{$i18n.t('Tell us more:')}</div>
<button
on:click={() => {
@ -81,9 +93,9 @@
<div class="flex flex-wrap gap-2 text-sm mt-2.5">
{#each reasons as reason}
<button
class="px-3.5 py-1 border dark:border-gray-850 dark:hover:bg-gray-850 {selectedReason ===
class="px-3.5 py-1 border dark:border-gray-850 hover:bg-gray-100 dark:hover:bg-gray-850 {selectedReason ===
reason
? 'dark:bg-gray-800'
? 'bg-gray-200 dark:bg-gray-800'
: ''} transition rounded-lg"
on:click={() => {
selectedReason = reason;
@ -99,7 +111,7 @@
<textarea
bind:value={comment}
class="w-full text-sm px-1 py-2 bg-transparent outline-none resize-none rounded-xl"
placeholder="Feel free to add specific details"
placeholder={$i18n.t('Feel free to add specific details')}
rows="2"
/>
</div>

View file

@ -15,7 +15,7 @@
const dispatch = createEventDispatcher();
import { config, settings } from '$lib/stores';
import { synthesizeOpenAISpeech } from '$lib/apis/openai';
import { synthesizeOpenAISpeech } from '$lib/apis/audio';
import { imageGenerations } from '$lib/apis/images';
import {
approximateToHumanReadable,
@ -176,10 +176,12 @@
const toggleSpeakMessage = async () => {
if (speaking) {
try {
speechSynthesis.cancel();
sentencesAudio[speakingIdx].pause();
sentencesAudio[speakingIdx].currentTime = 0;
} catch {}
speaking = null;
speakingIdx = null;
@ -221,6 +223,10 @@
sentence
).catch((error) => {
toast.error(error);
speaking = null;
loadingSpeech = false;
return null;
});
@ -230,7 +236,6 @@
const audio = new Audio(blobUrl);
sentencesAudio[idx] = audio;
loadingSpeech = false;
lastPlayedAudioPromise = lastPlayedAudioPromise.then(() => playAudio(idx));
}
}
@ -551,6 +556,12 @@
on:click={() => {
rateMessage(message.id, 1);
showRateComment = true;
window.setTimeout(() => {
document
.getElementById(`message-feedback-${message.id}`)
?.scrollIntoView();
}, 0);
}}
>
<svg
@ -580,6 +591,11 @@
on:click={() => {
rateMessage(message.id, -1);
showRateComment = true;
window.setTimeout(() => {
document
.getElementById(`message-feedback-${message.id}`)
?.scrollIntoView();
}, 0);
}}
>
<svg
@ -839,6 +855,7 @@
{#if showRateComment}
<RateComment
messageId={message.id}
bind:show={showRateComment}
bind:message
on:submit={() => {

View file

@ -176,10 +176,23 @@
e.target.style.height = '';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
on:keydown={(e) => {
if (e.key === 'Escape') {
document.getElementById('close-edit-message-button')?.click();
}
const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
const isEnterPressed = e.key === 'Enter';
if (isCmdOrCtrlPressed && isEnterPressed) {
document.getElementById('save-edit-message-button')?.click();
}
}}
/>
<div class=" mt-2 mb-1 flex justify-center space-x-2 text-sm font-medium">
<button
id="save-edit-message-button"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded-lg"
on:click={() => {
editMessageConfirmHandler();
@ -189,6 +202,7 @@
</button>
<button
id="close-edit-message-button"
class=" px-4 py-2 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-100 transition outline outline-1 outline-gray-200 dark:outline-gray-600 rounded-lg"
on:click={() => {
cancelEditMessage();

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { getAudioConfig, updateAudioConfig } from '$lib/apis/audio';
import { user } from '$lib/stores';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
const dispatch = createEventDispatcher();
@ -9,6 +11,9 @@
// Audio
let OpenAIUrl = '';
let OpenAIKey = '';
let STTEngines = ['', 'openai'];
let STTEngine = '';
@ -69,6 +74,18 @@
saveSettings({ speechAutoSend: speechAutoSend });
};
const updateConfigHandler = async () => {
const res = await updateAudioConfig(localStorage.token, {
url: OpenAIUrl,
key: OpenAIKey
});
if (res) {
OpenAIUrl = res.OPENAI_API_BASE_URL;
OpenAIKey = res.OPENAI_API_KEY;
}
};
onMount(async () => {
let settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
@ -85,12 +102,24 @@
} else {
getWebAPIVoices();
}
if ($user.role === 'admin') {
const res = await getAudioConfig(localStorage.token);
if (res) {
OpenAIUrl = res.OPENAI_API_BASE_URL;
OpenAIKey = res.OPENAI_API_KEY;
}
}
});
</script>
<form
class="flex flex-col h-full justify-between space-y-3 text-sm"
on:submit|preventDefault={() => {
on:submit|preventDefault={async () => {
if ($user.role === 'admin') {
await updateConfigHandler();
}
saveSettings({
audio: {
STTEngine: STTEngine !== '' ? STTEngine : undefined,
@ -101,7 +130,7 @@
dispatch('save');
}}
>
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[22rem]">
<div>
<div class=" mb-1 text-sm font-medium">{$i18n.t('STT Settings')}</div>
@ -196,6 +225,26 @@
</div>
</div>
{#if $user.role === 'admin'}
{#if TTSEngine === 'openai'}
<div class="mt-1 flex gap-2 mb-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={OpenAIUrl}
required
/>
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Key')}
bind:value={OpenAIKey}
required
/>
</div>
{/if}
{/if}
<div class=" py-0.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Auto-playback response')}</div>
@ -223,7 +272,7 @@
<div class="flex w-full">
<div class="flex-1">
<select
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={speaker}
placeholder="Select a voice"
>
@ -241,16 +290,18 @@
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Voice')}</div>
<div class="flex w-full">
<div class="flex-1">
<select
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
<input
list="voice-list"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={speaker}
placeholder="Select a voice"
>
/>
<datalist id="voice-list">
{#each voices as voice}
<option value={voice.name} class="bg-gray-100 dark:bg-gray-700">{voice.name}</option
>
<option value={voice.name} />
{/each}
</select>
</datalist>
</div>
</div>
</div>

View file

@ -288,20 +288,20 @@
<div class="flex border-b dark:border-gray-600 w-full">
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r dark:border-gray-600"
placeholder="Title (e.g. Tell me a fun fact)"
placeholder={$i18n.t('Title (e.g. Tell me a fun fact)')}
bind:value={prompt.title[0]}
/>
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r dark:border-gray-600"
placeholder="Subtitle (e.g. about the Roman Empire)"
placeholder={$i18n.t('Subtitle (e.g. about the Roman Empire)')}
bind:value={prompt.title[1]}
/>
</div>
<input
class="px-3 py-1.5 text-xs w-full bg-transparent outline-none border-r dark:border-gray-600"
placeholder="Prompt (e.g. Tell me a fun fact about the Roman Empire)"
placeholder={$i18n.t('Prompt (e.g. Tell me a fun fact about the Roman Empire)')}
bind:value={prompt.content}
/>
</div>

View file

@ -3,9 +3,7 @@
import { toast } from 'svelte-sonner';
import { models, settings, user } from '$lib/stores';
import { getOllamaModels } from '$lib/apis/ollama';
import { getOpenAIModels } from '$lib/apis/openai';
import { getLiteLLMModels } from '$lib/apis/litellm';
import { getModels } from '$lib/utils';
import Modal from '../common/Modal.svelte';
import Account from './Settings/Account.svelte';
@ -25,37 +23,11 @@
const saveSettings = async (updated) => {
console.log(updated);
await settings.set({ ...$settings, ...updated });
await models.set(await getModels());
await models.set(await getModels(localStorage.token));
localStorage.setItem('settings', JSON.stringify($settings));
};
let selectedTab = 'general';
const getModels = async () => {
let models = await Promise.all([
await getOllamaModels(localStorage.token).catch((error) => {
console.log(error);
return null;
}),
await getOpenAIModels(localStorage.token).catch((error) => {
console.log(error);
return null;
}),
await getLiteLLMModels(localStorage.token).catch((error) => {
console.log(error);
return null;
})
]);
models = models
.filter((models) => models)
.reduce((a, e, i, arr) => a.concat(e, ...(i < arr.length - 1 ? [{ name: 'hr' }] : [])), []);
// models.push(...(ollamaModels ? [{ name: 'hr' }, ...ollamaModels] : []));
// models.push(...(openAIModels ? [{ name: 'hr' }, ...openAIModels] : []));
// models.push(...(liteLLMModels ? [{ name: 'hr' }, ...liteLLMModels] : []));
return models;
};
</script>
<Modal bind:show>

View file

@ -3,24 +3,27 @@
import { toast } from 'svelte-sonner';
import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats';
import { chatId, modelfiles } from '$lib/stores';
import { modelfiles } from '$lib/stores';
import { copyToClipboard } from '$lib/utils';
import Modal from '../common/Modal.svelte';
import Link from '../icons/Link.svelte';
export let chatId;
let chat = null;
let shareUrl = null;
const i18n = getContext('i18n');
const shareLocalChat = async () => {
const _chat = chat;
const sharedChat = await shareChatById(localStorage.token, $chatId);
const chatShareUrl = `${window.location.origin}/s/${sharedChat.id}`;
const sharedChat = await shareChatById(localStorage.token, chatId);
shareUrl = `${window.location.origin}/s/${sharedChat.id}`;
console.log(shareUrl);
chat = await getChatById(localStorage.token, chatId);
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
copyToClipboard(chatShareUrl);
chat = await getChatById(localStorage.token, $chatId);
return shareUrl;
};
const shareChat = async () => {
@ -56,8 +59,8 @@
$: if (show) {
(async () => {
if ($chatId) {
chat = await getChatById(localStorage.token, $chatId);
if (chatId) {
chat = await getChatById(localStorage.token, chatId);
} else {
chat = null;
console.log(chat);
@ -101,10 +104,10 @@
<button
class="underline"
on:click={async () => {
const res = await deleteSharedChatById(localStorage.token, $chatId);
const res = await deleteSharedChatById(localStorage.token, chatId);
if (res) {
chat = await getChatById(localStorage.token, $chatId);
chat = await getChatById(localStorage.token, chatId);
}
}}>delete this link</button
> and create a new shared link.
@ -131,8 +134,12 @@
<button
class=" self-center flex items-center gap-1 px-3.5 py-2 rounded-xl text-sm font-medium bg-emerald-600 hover:bg-emerald-500 text-white"
type="button"
on:click={() => {
on:pointerdown={() => {
shareLocalChat();
}}
on:click={async () => {
copyToClipboard(shareUrl);
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
show = false;
}}
>

View file

@ -15,8 +15,10 @@
return 'w-[16rem]';
} else if (size === 'sm') {
return 'w-[30rem]';
} else {
} else if (size === 'md') {
return 'w-[44rem]';
} else {
return 'w-[48rem]';
}
};
@ -47,7 +49,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={modalElement}
class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full min-h-screen h-screen flex justify-center z-50 overflow-hidden overscroll-contain"
class=" fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full min-h-screen h-screen flex justify-center z-[9999] overflow-hidden overscroll-contain"
in:fade={{ duration: 10 }}
on:click={() => {
show = false;

View file

@ -29,8 +29,8 @@
let embeddingEngine = '';
let embeddingModel = '';
let openAIKey = '';
let openAIUrl = '';
let OpenAIKey = '';
let OpenAIUrl = '';
let chunkSize = 0;
let chunkOverlap = 0;
@ -79,7 +79,7 @@
return;
}
if ((embeddingEngine === 'openai' && openAIKey === '') || openAIUrl === '') {
if ((embeddingEngine === 'openai' && OpenAIKey === '') || OpenAIUrl === '') {
toast.error($i18n.t('OpenAI URL/Key required.'));
return;
}
@ -93,8 +93,8 @@
...(embeddingEngine === 'openai'
? {
openai_config: {
key: openAIKey,
url: openAIUrl
key: OpenAIKey,
url: OpenAIUrl
}
}
: {})
@ -133,8 +133,8 @@
embeddingEngine = embeddingConfig.embedding_engine;
embeddingModel = embeddingConfig.embedding_model;
openAIKey = embeddingConfig.openai_config.key;
openAIUrl = embeddingConfig.openai_config.url;
OpenAIKey = embeddingConfig.openai_config.key;
OpenAIUrl = embeddingConfig.openai_config.url;
}
};
@ -192,14 +192,14 @@
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Base URL')}
bind:value={openAIUrl}
bind:value={OpenAIUrl}
required
/>
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder={$i18n.t('API Key')}
bind:value={openAIKey}
bind:value={OpenAIKey}
required
/>
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
export let className = 'size-3.5';
export let strokeWidth = '2.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z"
/>
</svg>

View file

@ -0,0 +1,11 @@
<script lang="ts">
export let className = 'w-4 h-4';
</script>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
<path
fill-rule="evenodd"
d="M15.75 4.5a3 3 0 1 1 .825 2.066l-8.421 4.679a3.002 3.002 0 0 1 0 1.51l8.421 4.679a3 3 0 1 1-.729 1.31l-8.421-4.678a3 3 0 1 1 0-4.132l8.421-4.679a3 3 0 0 1-.096-.755Z"
clip-rule="evenodd"
/>
</svg>

View file

@ -25,7 +25,7 @@
let showDownloadChatModal = false;
</script>
<ShareChatModal bind:show={showShareChatModal} />
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
<nav id="nav" class=" sticky py-2.5 top-0 flex flex-row justify-center z-30">
<div
class=" flex {$settings?.fullScreenMode ?? null ? 'max-w-full' : 'max-w-3xl'}

View file

@ -17,13 +17,17 @@
getChatById,
getChatListByTagName,
updateChatById,
getAllChatTags
getAllChatTags,
archiveChatById
} from '$lib/apis/chats';
import { toast } from 'svelte-sonner';
import { fade, slide } from 'svelte/transition';
import { WEBUI_BASE_URL } from '$lib/constants';
import Tooltip from '../common/Tooltip.svelte';
import ChatMenu from './Sidebar/ChatMenu.svelte';
import ShareChatModal from '../chat/ShareChatModal.svelte';
import ArchiveBox from '../icons/ArchiveBox.svelte';
import ArchivedChatsModal from './Sidebar/ArchivedChatsModal.svelte';
let show = false;
let navElement;
@ -31,12 +35,16 @@
let title: string = 'UI';
let search = '';
let shareChatId = null;
let selectedChatId = null;
let chatDeleteId = null;
let chatTitleEditId = null;
let chatTitle = '';
let showArchivedChatsModal = false;
let showShareChatModal = false;
let showDropdown = false;
let isEditing = false;
@ -134,8 +142,21 @@
localStorage.setItem('settings', JSON.stringify($settings));
location.href = '/';
};
const archiveChatHandler = async (id) => {
await archiveChatById(localStorage.token, id);
await chats.set(await getChatList(localStorage.token));
};
</script>
<ShareChatModal bind:show={showShareChatModal} chatId={shareChatId} />
<ArchivedChatsModal
bind:show={showArchivedChatsModal}
on:change={async () => {
await chats.set(await getChatList(localStorage.token));
}}
/>
<div
bind:this={navElement}
class="h-screen max-h-[100dvh] min-h-screen {show
@ -544,9 +565,13 @@
</button>
</div>
{:else}
<div class="flex self-center space-x-1.5 z-10">
<div class="flex self-center space-x-1 z-10">
<ChatMenu
chatId={chat.id}
shareHandler={() => {
shareChatId = selectedChatId;
showShareChatModal = true;
}}
renameHandler={() => {
chatTitle = chat.title;
chatTitleEditId = chat.id;
@ -577,6 +602,18 @@
</svg>
</button>
</ChatMenu>
<Tooltip content="Archive">
<button
aria-label="Archive"
class=" self-center dark:hover:text-white transition"
on:click={() => {
archiveChatHandler(chat.id);
}}
>
<ArchiveBox />
</button>
</Tooltip>
</div>
{/if}
</div>
@ -609,13 +646,13 @@
{#if showDropdown}
<div
id="dropdownDots"
class="absolute z-40 bottom-[70px] 4.5rem rounded-xl shadow w-[240px] bg-white dark:bg-gray-900"
class="absolute z-40 bottom-[70px] rounded-lg shadow w-[240px] bg-white dark:bg-gray-900"
transition:fade|slide={{ duration: 100 }}
>
<div class="py-2 w-full">
<div class="p-1 py-2 w-full">
{#if $user.role === 'admin'}
<button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
class="flex rounded-md py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => {
goto('/admin');
showDropdown = false;
@ -641,7 +678,7 @@
</button>
<button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
class="flex rounded-md py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => {
goto('/playground');
showDropdown = false;
@ -668,7 +705,20 @@
{/if}
<button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
class="flex rounded-md py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => {
showArchivedChatsModal = true;
showDropdown = false;
}}
>
<div class=" self-center mr-3">
<ArchiveBox className="size-5" strokeWidth="1.5" />
</div>
<div class=" self-center font-medium">{$i18n.t('Archived Chats')}</div>
</button>
<button
class="flex rounded-md py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={async () => {
await showSettings.set(true);
showDropdown = false;
@ -699,11 +749,11 @@
</button>
</div>
<hr class=" dark:border-gray-700 m-0 p-0" />
<hr class=" dark:border-gray-800 m-0 p-0" />
<div class="py-2 w-full">
<div class="p-1 py-2 w-full">
<button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
class="flex rounded-md py-2.5 px-3.5 w-full hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => {
localStorage.removeItem('token');
location.href = '/auth';

View file

@ -0,0 +1,168 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import dayjs from 'dayjs';
import { getContext, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import Modal from '$lib/components/common/Modal.svelte';
import { archiveChatById, deleteChatById, getArchivedChatList } from '$lib/apis/chats';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let show = false;
let chats = [];
const unarchiveChatHandler = async (chatId) => {
const res = await archiveChatById(localStorage.token, chatId).catch((error) => {
toast.error(error);
});
chats = await getArchivedChatList(localStorage.token);
dispatch('change');
};
const deleteChatHandler = async (chatId) => {
const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
toast.error(error);
});
chats = await getArchivedChatList(localStorage.token);
};
$: if (show) {
(async () => {
chats = await getArchivedChatList(localStorage.token);
})();
}
</script>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 py-4">
<div class=" text-lg font-medium self-center">{$i18n.t('Archived Chats')}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<hr class=" dark:border-gray-850" />
<div class="flex flex-col md:flex-row w-full px-5 py-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 chats.length > 0}
<div class="text-left text-sm w-full mb-4">
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead
class="text-xs text-gray-700 uppercase bg-transparent dark:text-gray-200 border-b-2 border-gray-800"
>
<tr>
<th scope="col" class="px-3 py-2"> {$i18n.t('Name')} </th>
<th scope="col" class="px-3 py-2"> {$i18n.t('Created At')} </th>
<th scope="col" class="px-3 py-2 text-right" />
</tr>
</thead>
<tbody>
{#each chats as chat, idx}
<tr
class="bg-white {idx !== chats.length - 1 &&
'border-b'} dark:bg-gray-900 dark:border-gray-850 text-xs"
>
<td class="px-3 py-1 w-2/3">
<a href="/c/{chat.id}" target="_blank">
<div class=" underline line-clamp-1">
{chat.title}
</div>
</a>
</td>
<td class=" px-3 py-1">
{dayjs(chat.created_at * 1000).format($i18n.t('MMMM DD, YYYY HH:mm'))}
</td>
<td class="px-3 py-1 text-right">
<div class="flex justify-end w-full">
<Tooltip content="Unarchive Chat">
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
unarchiveChatHandler(chat.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3V15"
/>
</svg>
</button>
</Tooltip>
<Tooltip content="Delete Chat">
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
on:click={async () => {
deleteChatHandler(chat.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- {#each chats as chat}
<div>
{JSON.stringify(chat)}
</div>
{/each} -->
</div>
{:else}
<div class="text-left text-sm w-full mb-8">You have no archived conversations.</div>
{/if}
</div>
</div>
</div>
</Modal>

View file

@ -7,7 +7,9 @@
import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Tags from '$lib/components/chat/Tags.svelte';
import Share from '$lib/components/icons/Share.svelte';
export let shareHandler: Function;
export let renameHandler: Function;
export let deleteHandler: Function;
export let onClose: Function;
@ -31,12 +33,22 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[150px] rounded-lg px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-900 dark:text-white shadow"
class="w-full max-w-[180px] rounded-lg px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-900 dark:text-white shadow"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer dark:hover:bg-gray-850 rounded-md"
on:click={() => {
shareHandler();
}}
>
<Share />
<div class="flex items-center">Share</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer dark:hover:bg-gray-850 rounded-md"
on:click={() => {

View file

@ -1,5 +1,5 @@
{
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'с', 'м', 'ч', 'д', 'с' или '-1' за неограничен срок.",
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s', 'm', 'h', 'd', 'w' или '-1' за неограничен срок.",
"(Beta)": "(Бета)",
"(e.g. `sh webui.sh --api`)": "(например `sh webui.sh --api`)",
"(latest)": "(последна)",

View file

@ -1,5 +1,5 @@
{
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'秒', '分', '時間', '日', '週' または '-1' で無期限。",
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s', 'm', 'h', 'd', 'w' または '-1' で無期限。",
"(Beta)": "(ベータ版)",
"(e.g. sh webui.sh --api)": "(例: sh webui.sh --api)",
"(latest)": "(最新)",

View file

@ -0,0 +1,372 @@
{
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s', 'm', 'h', 'd', 'w' ან '-1' ვადის გასვლისთვის.",
"(Beta)": "(ბეტა)",
"(e.g. `sh webui.sh --api`)": "(მაგ. `sh webui.sh --api`)",
"(latest)": "(უახლესი)",
"{{modelName}} is thinking...": "{{modelName}} ფიქრობს...",
"{{webUIName}} Backend Required": "{{webUIName}} საჭიროა ბექენდი",
"a user": "მომხმარებელი",
"About": "შესახებ",
"Account": "ანგარიში",
"Action": "ქმედება",
"Add a model": "მოდელის დამატება",
"Add a model tag name": "მოდელის ტეგის სახელის დამატება",
"Add a short description about what this modelfile does": "დაამატე მოკლე აღწერა იმის შესახებ, თუ რას აკეთებს ეს მოდელური ფაილი",
"Add a short title for this prompt": "დაამატე მოკლე სათაური ამ მოთხოვნისთვის",
"Add a tag": "დაამატე ტეგი",
"Add Docs": "დოკუმენტის დამატება",
"Add Files": "ფაილების დამატება",
"Add message": "შეტყობინების დამატება",
"add tags": "ტეგების დამატება",
"Adjusting these settings will apply changes universally to all users.": "ამ პარამეტრების რეგულირება ცვლილებებს უნივერსალურად გამოიყენებს ყველა მომხმარებლისთვის",
"admin": "ადმინისტრატორი",
"Admin Panel": "ადმინ პანელი",
"Admin Settings": "ადმინისტრატორის ხელსაწყოები",
"Advanced Parameters": "დამატებითი პარამეტრები",
"all": "ყველა",
"All Users": "ყველა მომხმარებელი",
"Allow": "ნების დართვა",
"Allow Chat Deletion": "მიმოწერის წაშლის დაშვება",
"alphanumeric characters and hyphens": "ალფანუმერული სიმბოლოები და დეფისები",
"Already have an account?": "უკვე გაქვს ანგარიში?",
"an assistant": "ასისტენტი",
"and": "და",
"API Base URL": "API საბაზისო URL",
"API Key": "API გასაღები",
"API RPM": "API RPM",
"are allowed - Activate this command by typing": "დაშვებულია - ბრძანების გასააქტიურებლად აკრიფეთ:",
"Are you sure?": "დარწმუნებული ხარ?",
"Audio": "ხმოვანი",
"Auto-playback response": "ავტომატური დაკვრის პასუხი",
"Auto-send input after 3 sec.": "შეყვანის ავტომატური გაგზავნა 3 წამის შემდეგ ",
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 საბაზისო მისამართი",
"AUTOMATIC1111 Base URL is required.": "AUTOMATIC1111 საბაზისო მისამართი აუცილებელია",
"available!": "ხელმისაწვდომია!",
"Back": "უკან",
"Builder Mode": "მოდელის შექმნა",
"Cancel": "გაუქმება",
"Categories": "კატეგორიები",
"Change Password": "პაროლის შეცვლა",
"Chat": "მიმოწერა",
"Chat History": "მიმოწერის ისტორია",
"Chat History is off for this browser.": "მიმოწერის ისტორია ამ ბრაუზერისთვის გათიშულია",
"Chats": "მიმოწერები",
"Check Again": "თავიდან შემოწმება",
"Check for updates": "განახლებების ძიება",
"Checking for updates...": "მიმდინარეობს განახლებების ძიება...",
"Choose a model before saving...": "აირჩიეთ მოდელი შენახვამდე...",
"Chunk Overlap": "გადახურვა ფრაგმენტულია",
"Chunk Params": "გადახურვის პარამეტრები",
"Chunk Size": "გადახურვის ზომა",
"Click here for help.": "დახმარებისთვის, დააკლიკე აქ",
"Click here to check other modelfiles.": "სხვა მოდელური ფაილების სანახავად, დააკლიკე აქ",
"Click here to select": "ასარჩევად, დააკლიკე აქ",
"Click here to select documents.": "დოკუმენტების ასარჩევად, დააკლიკე აქ",
"click here.": "დააკლიკე აქ",
"Click on the user role button to change a user's role.": "დააკლიკეთ მომხმარებლის როლის ღილაკს რომ შეცვალოთ მომხმარების როლი",
"Close": "დახურვა",
"Collection": "ნაკრები",
"Command": "ბრძანება",
"Confirm Password": "პაროლის დამოწმება",
"Connections": "კავშირები",
"Content": "კონტენტი",
"Context Length": "კონტექსტის სიგრძე",
"Conversation Mode": "საუბრი რეჟიმი",
"Copy last code block": "ბოლო ბლოკის კოპირება",
"Copy last response": "ბოლო პასუხის კოპირება",
"Copying to clipboard was successful!": "კლავიატურაზე კოპირება წარმატებით დასრულდა",
"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "შექმენით მოკლე, 3-5 სიტყვიანი ფრაზა, როგორც სათაური თქვენი შემდეგი შეკითხვისთვის, მკაცრად დაიცავით 3-5 სიტყვის ლიმიტი და მოერიდეთ გამოიყენოთ სიტყვა „სათაური“.",
"Create a modelfile": "მოდელური ფაილის შექმნა",
"Create Account": "ანგარიშის შექმნა",
"Created at": "შექმნილია",
"Created by": "ავტორი",
"Current Model": "მიმდინარე მოდელი",
"Current Password": "მიმდინარე პაროლი",
"Custom": "საკუთარი",
"Customize Ollama models for a specific purpose": "Ollama მოდელების დამუშავება სპეციფიური დანიშნულებისთვის",
"Dark": "მუქი",
"Database": "მონაცემთა ბაზა",
"DD/MM/YYYY HH:mm": "DD/MM/YYYY HH:mm",
"Default": "დეფოლტი",
"Default (Automatic1111)": "დეფოლტ (Automatic1111)",
"Default (Web API)": "დეფოლტ (Web API)",
"Default model updated": "დეფოლტ მოდელი განახლებულია",
"Default Prompt Suggestions": "",
"Default User Role": "მომხმარებლის დეფოლტ როლი",
"delete": "წაშლა",
"Delete a model": "მოდელის წაშლა",
"Delete chat": "შეტყობინების წაშლა",
"Delete Chats": "შეტყობინებების წაშლა",
"Deleted {{deleteModelTag}}": "{{deleteModelTag}} წაშლილია",
"Deleted {tagName}": "{tagName} წაშლილია",
"Description": "აღწერა",
"Notifications": "შეტყობინება",
"Disabled": "გაუქმებულია",
"Discover a modelfile": "აღმოაჩინეთ მოდელური ფაილი",
"Discover a prompt": "აღმოაჩინეთ მოთხოვნა",
"Discover, download, and explore custom prompts": "აღმოაჩინეთ, ჩამოტვირთეთ და შეისწავლეთ მორგებული მოთხოვნები",
"Discover, download, and explore model presets": "აღმოაჩინეთ, ჩამოტვირთეთ და შეისწავლეთ მოდელის წინასწარ პარამეტრები",
"Display the username instead of You in the Chat": "ჩატში აჩვენე მომხმარებლის სახელი თქვენს ნაცვლად",
"Document": "დოკუმენტი",
"Document Settings": "დოკუმენტის პარამეტრები",
"Documents": "დოკუმენტები",
"does not make any external connections, and your data stays securely on your locally hosted server.": "არ ამყარებს გარე კავშირებს და თქვენი მონაცემები უსაფრთხოდ რჩება თქვენს ადგილობრივ სერვერზე.",
"Don't Allow": "არ დაუშვა",
"Don't have an account?": "არ გაქვს ანგარიში?",
"Download as a File": "გადმოწერე როგორც ფაილი",
"Download Database": "გადმოწერე მონაცემთა ბაზა",
"Drop any files here to add to the conversation": "გადაიტანეთ ფაილები აქ, რათა დაამატოთ ისინი მიმოწერაში",
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "მაგალითად, '30წ', '10მ'. მოქმედი დროის ერთეულები: 'წ', 'წთ', 'სთ'.",
"Edit Doc": "დოკუმენტის ედიტირება",
"Edit User": "მომხმარებლის ედიტირება",
"Email": "ელ-ფოსტა",
"Embedding model: {{embedding_model}}": "ჩაშენების მოდელი: {{embedding_model}}",
"Enable Chat History": "მიმოწერის ისტორიის ჩართვა",
"Enable New Sign Ups": "ახალი რეგისტრაციების ჩართვა",
"Enabled": "ჩართულია",
"Enter {{role}} message here": "შეიყვანე {{role}} შეტყობინება აქ",
"Enter API Key": "შეიყვანე API Key",
"Enter Chunk Overlap": "შეიყვანეთ ნაწილის გადახურვა",
"Enter Chunk Size": "შეიყვანე ბლოკის ზომა",
"Enter Image Size (e.g. 512x512)": "შეიყვანეთ სურათის ზომა (მაგ. 512x512)",
"Enter LiteLLM API Base URL (litellm_params.api_base)": "შეიყვანეთ LiteLLM API ბაზის მისამართი (litellm_params.api_base)",
"Enter LiteLLM API Key (litellm_params.api_key)": "შეიყვანეთ LiteLLM API გასაღები (litellm_params.api_key)",
"Enter LiteLLM API RPM (litellm_params.rpm)": "შეიყვანეთ LiteLLM API RPM (litellm_params.rpm)",
"Enter LiteLLM Model (litellm_params.model)": "შეიყვანეთ LiteLLM მოდელი (litellm_params.model)",
"Enter Max Tokens (litellm_params.max_tokens)": "შეიყვანეთ მაქსიმალური ტოკენები (litellm_params.max_tokens)",
"Enter model tag (e.g. {{modelTag}})": "შეიყვანეთ მოდელის ტეგი (მაგ. {{modelTag}})",
"Enter Number of Steps (e.g. 50)": "შეიყვანეთ ნაბიჯების რაოდენობა (მაგ. 50)",
"Enter stop sequence": "შეიყვანეთ ტოპ თანმიმდევრობა",
"Enter Top K": "შეიყვანეთ Top K",
"Enter URL (e.g. http://127.0.0.1:7860/)": "შეიყვანეთ მისამართი (მაგალითად http://127.0.0.1:7860/)",
"Enter Your Email": "შეიყვანეთ თქვენი ელ-ფოსტა",
"Enter Your Full Name": "შეიყვანეთ თქვენი სრული სახელი",
"Enter Your Password": "შეიყვანეთ თქვენი პაროლი",
"Experimental": "ექსპერიმენტალური",
"Export All Chats (All Users)": "",
"Export Chats": "მიმოწერის ექსპორტირება",
"Export Documents Mapping": "დოკუმენტების კავშირის ექსპორტი",
"Export Modelfiles": "მოდელური ფაილების ექსპორტი",
"Export Prompts": "მოთხოვნების ექსპორტი",
"Failed to read clipboard contents": "ბუფერში შიგთავსის წაკითხვა ვერ მოხერხდა",
"File Mode": "ფაილური რეჟიმი",
"File not found.": "ფაილი ვერ მოიძებნა",
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "აღმოჩენილია თითის ანაბეჭდის გაყალბება: ინიციალების გამოყენება ავატარად შეუძლებელია. დეფოლტ პროფილის დეფოლტ სურათი.",
"Focus chat input": "ჩეთის შეყვანის ფოკუსი",
"Format your variables using square brackets like this:": "დააფორმატეთ თქვენი ცვლადები კვადრატული ფრჩხილების გამოყენებით:",
"From (Base Model)": "(საბაზო მოდელი) დან",
"Full Screen Mode": "Სრული ეკრანის რეჟიმი",
"General": "ზოგადი",
"General Settings": "ზოგადი პარამეტრები",
"Hello, {{name}}": "გამარჯობა, {{name}}",
"Hide": "დამალვა",
"Hide Additional Params": "დამატებითი პარამეტრების დამალვა",
"How can I help you today?": "როგორ შემიძლია დაგეხმარო დღეს?",
"Image Generation (Experimental)": "სურათების გენერაცია (ექსპერიმენტული)",
"Image Generation Engine": "სურათის გენერაციის ძრავა",
"Image Settings": "სურათის პარამეტრები",
"Images": "სურათები",
"Import Chats": "მიმოწერების იმპორტი",
"Import Documents Mapping": "დოკუმენტების კავშირის იმპორტი",
"Import Modelfiles": "მოდელური ფაილების იმპორტი",
"Import Prompts": "მოთხოვნების იმპორტი",
"Include `--api` flag when running stable-diffusion-webui": "ჩართეთ `--api` დროშა stable-diffusion-webui-ის გაშვებისას",
"Interface": "ინტერფეისი",
"join our Discord for help.": "შეუერთდით ჩვენს Discord-ს დახმარებისთვის",
"JSON": "JSON",
"JWT Expiration": "JWT-ის ვადა",
"JWT Token": "JWT ტოკენი",
"Keep Alive": "აქტიურად დატოვება",
"Keyboard shortcuts": "კლავიატურის მალსახმობები",
"Language": "ენა",
"Light": "მსუბუქი",
"Listening...": "გისმენ...",
"LLMs can make mistakes. Verify important information.": "შესაძლოა LLM-ებმა შეცდომები დაუშვან. გადაამოწმეთ მნიშვნელოვანი ინფორმაცია.",
"Made by OpenWebUI Community": "დამზადებულია OpenWebUI საზოგადოების მიერ",
"Make sure to enclose them with": "დარწმუნდით, რომ დაურთეთ ისინი",
"Manage LiteLLM Models": "LiteLLM მოდელების მართვა",
"Manage Models": "მოდელების მართვა",
"Manage Ollama Models": "Ollama მოდელების მართვა",
"Max Tokens": "მაქსიმალური ტოკენები",
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "მაქსიმუმ 3 მოდელის ჩამოტვირთვა შესაძლებელია ერთდროულად. Გთხოვთ სცადოთ მოგვიანებით.",
"Mirostat": "მიროსტატი",
"Mirostat Eta": "მიროსტატი ეტა",
"Mirostat Tau": "მიროსტატი ტაუ",
"MMMM DD, YYYY": "თვე დღე, წელი",
"Model '{{modelName}}' has been successfully downloaded.": "მოდელი „{{modelName}}“ წარმატებით ჩამოიტვირთა.",
"Model '{{modelTag}}' is already in queue for downloading.": "მოდელი „{{modelTag}}“ უკვე ჩამოტვირთვის რიგშია.",
"Model {{embedding_model}} update complete!": "მოდელის {{embedding_model}} განახლება დასრულდა!",
"Model {{embedding_model}} update failed or not required!": "მოდელის {{embedding_model}} განახლება ვერ მოხერხდა ან არ არის საჭირო!",
"Model {{modelId}} not found": "მოდელი {{modelId}} ვერ მოიძებნა",
"Model {{modelName}} already exists.": "მოდელი {{modelName}} უკვე არსებობს.",
"Model filesystem path detected. Model shortname is required for update, cannot continue.": "აღმოჩენილია მოდელის ფაილური სისტემის გზა. განახლებისთვის საჭიროა მოდელის მოკლე სახელი, გაგრძელება შეუძლებელია.",
"Model Name": "მოდელის სახელი",
"Model not selected": "მოდელი არ არის არჩეული",
"Model Tag Name": "მოდელის ტეგის სახელი",
"Model Whitelisting": "მოდელის თეთრ სიაში შეყვანა",
"Model(s) Whitelisted": "მოდელ(ებ)ი თეთრ სიაშია",
"Modelfile": "მოდელური ფაილი",
"Modelfile Advanced Settings": "მოდელური ფაილის პარამეტრები",
"Modelfile Content": "მოდელური ფაილის კონტენტი",
"Modelfiles": "მოდელური ფაილები",
"Models": "მოდელები",
"My Documents": "ჩემი დოკუმენტები",
"My Modelfiles": "ჩემი მოდელური ფაილები",
"My Prompts": "ჩემი მოთხოვნები",
"Name": "სახელი",
"Name Tag": "სახელის ტეგი",
"Name your modelfile": "თქვენი მოდელური ფაილის სახელი",
"New Chat": "ახალი მიმოწერა",
"New Password": "ახალი პაროლი",
"Not sure what to add?": "არ იცი რა დაამატო?",
"Not sure what to write? Switch to": "არ იცი რა დაწერო? გადართვა:",
"Off": "გამორთვა",
"Okay, Let's Go!": "კარგი, წავედით!",
"Ollama Base URL": "Ollama ბაზისური მისამართი",
"Ollama Version": "Ollama ვერსია",
"On": "ჩართვა",
"Only": "მხოლოდ",
"Only alphanumeric characters and hyphens are allowed in the command string.": "ბრძანების სტრიქონში დაშვებულია მხოლოდ ალფანუმერული სიმბოლოები და დეფისები.",
"Oops! Hold tight! Your files are still in the processing oven. We're cooking them up to perfection. Please be patient and we'll let you know once they're ready.": "უპს! გამაგრდი! თქვენი ფაილები ჯერ კიდევ დამუშავების ღუმელშია. ჩვენ მათ სრულყოფილებამდე ვამზადებთ. გთხოვთ მოითმინოთ და ჩვენ შეგატყობინებთ, როგორც კი ისინი მზად იქნებიან.",
"Oops! Looks like the URL is invalid. Please double-check and try again.": "უი! როგორც ჩანს, მისამართი არასწორია. გთხოვთ, გადაამოწმოთ და ისევ სცადოთ.",
"Oops! You're using an unsupported method (frontend only). Please serve the WebUI from the backend.": "უპს! თქვენ იყენებთ მხარდაუჭერელ მეთოდს (მხოლოდ frontend). გთხოვთ, მოემსახუროთ WebUI-ს ბექენდიდან",
"Open": "ღია",
"Open AI": "ღია AI",
"Open AI (Dall-E)": "Open AI (Dall-E)",
"Open new chat": "ახალი მიმოწერის გახსნა",
"OpenAI API": "OpenAI API",
"OpenAI API Key": "OpenAI API გასაღები",
"OpenAI API Key is required.": "OpenAI API გასაღები აუცილებელია",
"or": "ან",
"Parameters": "პარამეტრები",
"Password": "პაროლი",
"PDF Extract Images (OCR)": "PDF იდან ამოღებული სურათები (OCR)",
"pending": "ლოდინის რეჟიმშია",
"Permission denied when accessing microphone: {{error}}": "ნებართვა უარყოფილია მიკროფონზე წვდომისას: {{error}}",
"Playground": "სათამაშო მოედანი",
"Profile": "პროფილი",
"Prompt Content": "მოთხოვნის შინაარსი",
"Prompt suggestions": "მოთხოვნის რჩევები",
"Prompts": "მოთხოვნები",
"Pull a model from Ollama.com": "Ollama.com იდან მოდელის გადაწერა ",
"Pull Progress": "პროგრესის გადაწერა",
"Query Params": "პარამეტრების ძიება",
"RAG Template": "RAG შაბლონი",
"Raw Format": "საწყისი ფორმატი",
"Record voice": "ხმის ჩაწერა",
"Redirecting you to OpenWebUI Community": "გადამისამართდებით OpenWebUI საზოგადოებაში",
"Release Notes": "Გამოშვების შენიშვნები",
"Repeat Last N": "გაიმეორეთ ბოლო N",
"Repeat Penalty": "გაიმეორეთ პენალტი",
"Request Mode": "მოთხოვნის რეჟიმი",
"Reset Vector Storage": "ვექტორული მეხსიერების გადატვირთვა",
"Response AutoCopy to Clipboard": "პასუხის ავტომატური კოპირება ბუფერში",
"Role": "როლი",
"Rosé Pine": "ვარდისფერი ფიჭვის ხე",
"Rosé Pine Dawn": "ვარდისფერი ფიჭვის გარიჟრაჟი",
"Save": "შენახვა",
"Save & Create": "დამახსოვრება და შექმნა",
"Save & Submit": "დამახსოვრება და გაგზავნა",
"Save & Update": "დამახსოვრება და განახლება",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "ჩეთის ისტორიის შენახვა პირდაპირ თქვენი ბრაუზერის საცავში აღარ არის მხარდაჭერილი. გთხოვთ, დაუთმოთ და წაშალოთ თქვენი ჩატის ჟურნალები ქვემოთ მოცემულ ღილაკზე დაწკაპუნებით. არ ინერვიულოთ, თქვენ შეგიძლიათ მარტივად ხელახლა შემოიტანოთ თქვენი ჩეთის ისტორია ბექენდში",
"Scan": "სკანირება",
"Scan complete!": "სკანირება დასრულდა!",
"Scan for documents from {{path}}": "დოკუმენტების სკანირება {{ path}}-დან",
"Search": "ძიება",
"Search Documents": "დოკუმენტების ძიება",
"Search Prompts": "მოთხოვნების ძიება",
"See readme.md for instructions": "იხილეთ readme.md ინსტრუქციებისთვის",
"See what's new": "სიახლეების ნახვა",
"Seed": "სიდი",
"Select a mode": "რეჟიმის არჩევა",
"Select a model": "მოდელის არჩევა",
"Select an Ollama instance": "",
"Send a Message": "შეტყობინების გაგზავნა",
"Send message": "შეტყობინების გაგზავნა",
"Server connection verified": "სერვერთან კავშირი დადასტურებულია",
"Set as default": "დეფოლტად დაყენება",
"Set Default Model": "დეფოლტ მოდელის დაყენება",
"Set Image Size": "სურათის ზომის დაყენება",
"Set Steps": "ნაბიჯების დაყენება",
"Set Title Auto-Generation Model": "სათაურის ავტომატური გენერაციის მოდელის დაყენება",
"Set Voice": "ხმის დაყენება",
"Settings": "ხელსაწყოები",
"Settings saved successfully!": "პარამეტრები წარმატებით განახლდა!",
"Share to OpenWebUI Community": "გააზიარე OpenWebUI საზოგადოებაში ",
"short-summary": "მოკლე შინაარსი",
"Show": "ჩვენება",
"Show Additional Params": "დამატებითი პარამეტრების ჩვენება",
"Show shortcuts": "მალსახმობების ჩვენება",
"sidebar": "საიდბარი",
"Sign in": "ავტორიზაცია",
"Sign Out": "გასვლა",
"Sign up": "რეგისტრაცია",
"Speech recognition error: {{error}}": "მეტყველების ამოცნობის შეცდომა: {{error}}",
"Speech-to-Text Engine": "ხმოვან-ტექსტური ძრავი",
"SpeechRecognition API is not supported in this browser.": "მეტყველების ამოცნობის API არ არის მხარდაჭერილი ამ ბრაუზერში.",
"Stop Sequence": "შეჩერების თანმიმდევრობა",
"STT Settings": "მეტყველების ამოცნობის პარამეტრები",
"Submit": "გაგზავნა",
"Success": "წარმატება",
"Successfully updated.": "წარმატებით განახლდა",
"Sync All": "სინქრონიზაცია",
"System": "სისტემა",
"System Prompt": "სისტემური მოთხოვნა",
"Tags": "ტეგები",
"Temperature": "ტემპერატურა",
"Template": "შაბლონი",
"Text Completion": "ტექსტის დასრულება",
"Text-to-Speech Engine": "ტექსტურ-ხმოვანი ძრავი",
"Tfs Z": "Tfs Z",
"Theme": "თემა",
"This ensures that your valuable conversations are securely saved to your backend database. Thank you!": "ეს უზრუნველყოფს, რომ თქვენი ძვირფასი საუბრები უსაფრთხოდ შეინახება თქვენს backend მონაცემთა ბაზაში. Გმადლობთ!",
"This setting does not sync across browsers or devices.": "ეს პარამეტრი არ სინქრონიზდება ბრაუზერებსა და მოწყობილობებში",
"Tip: Update multiple variable slots consecutively by pressing the tab key in the chat input after each replacement.": "რჩევა: განაახლეთ რამდენიმე ცვლადი სლოტი თანმიმდევრულად, ყოველი ჩანაცვლების შემდეგ ჩატის ღილაკზე დაჭერით.",
"Title": "სათაური",
"Title Auto-Generation": "სათაურის ავტო-გენერაცია",
"Title Generation Prompt": "სათაურის გენერაციის მოთხოვნა ",
"to": "ში",
"To access the available model names for downloading,": "ჩამოტვირთვისთვის ხელმისაწვდომი მოდელების სახელებზე წვდომისთვის",
"To access the GGUF models available for downloading,": "ჩასატვირთად ხელმისაწვდომი GGUF მოდელებზე წვდომისთვის",
"to chat input.": "ჩატში",
"Toggle settings": "პარამეტრების გადართვა",
"Toggle sidebar": "გვერდითი ზოლის გადართვა",
"Top K": "ტოპ K",
"Top P": "ტოპ P",
"Trouble accessing Ollama?": "Ollama-ს ვერ უკავშირდები?",
"TTS Settings": "TTS პარამეტრები",
"Type Hugging Face Resolve (Download) URL": "სცადე გადმოწერო Hugging Face Resolve URL",
"Uh-oh! There was an issue connecting to {{provider}}.": "{{provider}}-თან დაკავშირების პრობლემა წარმოიშვა.",
"Understand that updating or changing your embedding model requires reset of the vector database and re-import of all documents. You have been warned!": "გაითვალისწინეთ, რომ თქვენი ჩაშენების მოდელის განახლება ან შეცვლა მოითხოვს ვექტორული მონაცემთა ბაზის გადატვირთვას და ყველა დოკუმენტის ხელახლა იმპორტს. ფრთხილად!",
"Unknown File Type '{{file_type}}', but accepting and treating as plain text": "უცნობი ფაილის ტიპი „{{file_type}}“, მაგრამ მიიღება და განიხილება როგორც მარტივი ტექსტი",
"Update": "განახლება",
"Update embedding model {{embedding_model}}": "განაახლე ჩაშენების მოდელი {{embedding_model}}",
"Update password": "პაროლის განახლება",
"Upload a GGUF model": "GGUF მოდელის ატვირთვა",
"Upload files": "ფაილების ატვირთვა",
"Upload Progress": "პროგრესის ატვირთვა",
"URL Mode": "URL რეჟიმი",
"Use '#' in the prompt input to load and select your documents.": "",
"Use Gravatar": "გამოიყენე Gravatar",
"Use Initials": "გამოიყენე ინიციალები",
"user": "მომხმარებელი",
"User Permissions": "მომხმარებლის უფლებები",
"Users": "მომხმარებლები",
"Utilize": "გამოყენება",
"Valid time units:": "მოქმედი დროის ერთეულები",
"variable": "ცვლადი",
"variable to have them replaced with clipboard content.": "ცვლადი, რომ შეცვალოს ისინი ბუფერში შიგთავსით.",
"Version": "ვერსია",
"Web": "ვები",
"WebUI Add-ons": "WebUI დანამატები",
"WebUI Settings": "WebUI პარამეტრები",
"WebUI will make requests to": "WebUI გამოგიგზავნით მოთხოვნებს",
"Whats New in": "რა არის ახალი",
"When history is turned off, new chats on this browser won't appear in your history on any of your devices.": "როდესაც ისტორია გამორთულია, ახალი ჩეთები ამ ბრაუზერში არ გამოჩნდება თქვენს ისტორიაში არცერთ მოწყობილობაზე.",
"Whisper (Local)": "ჩურჩული (ადგილობრივი)",
"Write a prompt suggestion (e.g. Who are you?)": "დაწერეთ მოკლე წინადადება (მაგ. ვინ ხარ?",
"Write a summary in 50 words that summarizes [topic or keyword].": "დაწერეთ რეზიუმე 50 სიტყვით, რომელიც აჯამებს [თემას ან საკვანძო სიტყვას].",
"You": "თქვენ",
"You're a helpful assistant.": "თქვენ სასარგებლო ასისტენტი ხართ.",
"You're now logged in.": "თქვენ შესული ხართ."
}

View file

@ -1,5 +1,5 @@
{
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'초', '분', '시간', '일', '주' 또는 만료 없음 '-1'",
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s', 'm', 'h', 'd', 'w' 또는 만료 없음 '-1'",
"(Beta)": "(Beta)",
"(e.g. `sh webui.sh --api`)": "(예: `sh webui.sh --api`)",
"(latest)": "(latest)",

View file

@ -43,6 +43,10 @@
"code": "ja-JP",
"title": "Japanese"
},
{
"code": "ka-GE",
"title": "Georgian"
},
{
"code": "ko-KR",
"title": "Korean"

View file

@ -86,9 +86,9 @@
"Customize Ollama models for a specific purpose": "Dostosuj modele Ollama do określonego celu",
"Dark": "Ciemny",
"Database": "Baza danych",
"DD/MM/YYYY HH:mm": "DD/MM/RRRR GG:MM",
"DD/MM/YYYY HH:mm": "DD/MM/YYYY HH:mm",
"Default": "Domyślny",
"Default (Automatic1111)": "Domyślny (Automatyczny1111)",
"Default (Automatic1111)": "Domyślny (Automatic1111)",
"Default (Web API)": "Domyślny (Interfejs API)",
"Default model updated": "Domyślny model zaktualizowany",
"Default Prompt Suggestions": "Domyślne sugestie promptów",

View file

@ -1,5 +1,5 @@
{
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s', 'm', 'h', 'd', 's' ou '-1' para não expirar.",
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s', 'm', 'h', 'd', 'w' ou '-1' para não expirar.",
"(Beta)": "(Beta)",
"(e.g. `sh webui.sh --api`)": "(por exemplo, `sh webui.sh --api`)",
"(latest)": "(mais recente)",

View file

@ -1,5 +1,5 @@
{
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s', 'm', 'h', 'd', 's' ou '-1' para nenhuma expiração.",
"'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s', 'm', 'h', 'd', 'w' ou '-1' para nenhuma expiração.",
"(Beta)": "(Beta)",
"(e.g. `sh webui.sh --api`)": "(por exemplo, `sh webui.sh --api`)",
"(latest)": "(mais recente)",
@ -86,7 +86,7 @@
"Customize Ollama models for a specific purpose": "Personalize os modelos Ollama para um propósito específico",
"Dark": "Escuro",
"Database": "Banco de dados",
"DD/MM/YYYY HH:mm": "DD/MM/AAAA HH:mm",
"DD/MM/YYYY HH:mm": "DD/MM/YYYY HH:mm",
"Default": "Padrão",
"Default (Automatic1111)": "Padrão (Automatic1111)",
"Default (Web API)": "Padrão (API Web)",
@ -190,7 +190,7 @@
"Mirostat": "Mirostat",
"Mirostat Eta": "Mirostat Eta",
"Mirostat Tau": "Mirostat Tau",
"MMMM DD, YYYY": "MMMM DD, AAAA",
"MMMM DD, YYYY": "DD/MM/YYYY",
"Model '{{modelName}}' has been successfully downloaded.": "O modelo '{{modelName}}' foi baixado com sucesso.",
"Model '{{modelTag}}' is already in queue for downloading.": "O modelo '{{modelTag}}' já está na fila para download.",
"Model {{modelId}} not found": "Modelo {{modelId}} não encontrado",

View file

@ -88,7 +88,7 @@
"Database": "База данных",
"DD/MM/YYYY HH:mm": "DD/MM/YYYY HH:mm",
"Default": "По умолчанию",
"Default (Automatic1111)": "По умолчанию (Автоматический1111)",
"Default (Automatic1111)": "По умолчанию (Automatic1111)",
"Default (Web API)": "По умолчанию (Web API)",
"Default model updated": "Модель по умолчанию обновлена",
"Default Prompt Suggestions": "Предложения промтов по умолчанию",

View file

@ -359,5 +359,20 @@
"Write a summary in 50 words that summarizes [topic or keyword].": "Viết một tóm tắt trong vòng 50 từ cho [chủ đề hoặc từ khóa].",
"You": "Bạn",
"You're a helpful assistant.": "Bạn là một trợ lý hữu ích.",
"You're now logged in.": "Bạn đã đăng nhập."
"You're now logged in.": "Bạn đã đăng nhập.",
"Accurate information": "Thông tin chính xác",
"Followed instructions perfectly": "Tuân theo chỉ dẫn một cách hoàn hảo",
"Showcased creativity": "Thể hiện sự sáng tạo",
"Positive attitude": "Thể hiện thái độ tích cực",
"Attention to detail": "Có sự chú ý đến chi tiết của vấn đề",
"Thorough explanation": "Giải thích kỹ lưỡng",
"Don't like the style": "Không thích phong cách trả lời",
"Not factually correct": "Không chính xác so với thực tế",
"Didn't fully follow instructions": "Không tuân theo chỉ dẫn một cách đầy đủ",
"Refused when it shouldn't have": "Từ chối trả lời mà nhẽ không nên làm vậy",
"Being lazy": "Lười biếng",
"Other": "Khác",
"Thanks for your feedback!": "Cám ơn bạn đã gửi phản hồi!",
"Tell us more:": "Hãy cho chúng tôi hiểu thêm về chất lượng của câu trả lời:",
"Feel free to add specific details": "Mô tả chi tiết về chất lượng của câu hỏi và phương án trả lời"
}

View file

@ -20,9 +20,7 @@ export const getModels = async (token: string) => {
})
]);
models = models
.filter((models) => models)
.reduce((a, e, i, arr) => a.concat(e, ...(i < arr.length - 1 ? [{ name: 'hr' }] : [])), []);
models = models.filter((models) => models).reduce((a, e, i, arr) => a.concat(e), []);
return models;
};
@ -187,7 +185,8 @@ export const generateInitialsImage = (name) => {
return canvas.toDataURL();
};
export const copyToClipboard = (text) => {
export const copyToClipboard = async (text) => {
let result = false;
if (!navigator.clipboard) {
const textArea = document.createElement('textarea');
textArea.value = text;
@ -205,21 +204,27 @@ export const copyToClipboard = (text) => {
const successful = document.execCommand('copy');
const msg = successful ? 'successful' : 'unsuccessful';
console.log('Fallback: Copying text command was ' + msg);
result = true;
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
return;
return result;
}
navigator.clipboard.writeText(text).then(
function () {
result = await navigator.clipboard
.writeText(text)
.then(() => {
console.log('Async: Copying to clipboard was successful!');
},
function (err) {
console.error('Async: Could not copy text: ', err);
}
);
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
return result;
};
export const compareVersion = (latest, current) => {

View file

@ -681,16 +681,18 @@
}
} else {
toast.error(
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { provider: model })
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model.name ?? model.id
})
);
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model
provider: model.name ?? model.id
});
}
responseMessage.error = true;
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model
provider: model.name ?? model.id
});
responseMessage.done = true;
messages = messages;

View file

@ -693,16 +693,18 @@
}
} else {
toast.error(
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { provider: model })
$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model.name ?? model.id
})
);
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model
provider: model.name ?? model.id
});
}
responseMessage.error = true;
responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
provider: model
provider: model.name ?? model.id
});
responseMessage.done = true;
messages = messages;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

4
static/logo.svg Normal file
View file

@ -0,0 +1,4 @@
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="347.666" y="139" width="44.3349" height="221.675" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M202.643 360.287C263.75 360.287 313.287 310.75 313.287 249.643C313.287 188.537 263.75 139 202.643 139C141.537 139 92 188.537 92 249.643C92 310.75 141.537 360.287 202.643 360.287ZM202.645 316.029C239.309 316.029 269.031 286.307 269.031 249.643C269.031 212.979 239.309 183.257 202.645 183.257C165.981 183.257 136.259 212.979 136.259 249.643C136.259 286.307 165.981 316.029 202.645 316.029Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 634 B