Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
chenpangpang
open-webui
Commits
25e0f0de
Commit
25e0f0de
authored
Mar 06, 2024
by
Ased Mammad
Browse files
Merge remote-tracking branch 'upstream/dev' into feat/add-i18n
parents
df8aeb39
3455f899
Changes
26
Show whitespace changes
Inline
Side-by-side
Showing
20 changed files
with
1372 additions
and
556 deletions
+1372
-556
.github/ISSUE_TEMPLATE/bug_report.md
.github/ISSUE_TEMPLATE/bug_report.md
+1
-1
backend/apps/ollama/main.py
backend/apps/ollama/main.py
+834
-12
backend/apps/ollama/old_main.py
backend/apps/ollama/old_main.py
+0
-127
backend/apps/rag/main.py
backend/apps/rag/main.py
+8
-2
backend/constants.py
backend/constants.py
+2
-0
backend/main.py
backend/main.py
+9
-1
backend/requirements.txt
backend/requirements.txt
+1
-0
src/lib/apis/ollama/index.ts
src/lib/apis/ollama/index.ts
+42
-27
src/lib/components/chat/MessageInput.svelte
src/lib/components/chat/MessageInput.svelte
+8
-13
src/lib/components/chat/Messages/ResponseMessage.svelte
src/lib/components/chat/Messages/ResponseMessage.svelte
+4
-4
src/lib/components/chat/Messages/UserMessage.svelte
src/lib/components/chat/Messages/UserMessage.svelte
+6
-6
src/lib/components/chat/Settings/Account.svelte
src/lib/components/chat/Settings/Account.svelte
+5
-5
src/lib/components/chat/Settings/Chats.svelte
src/lib/components/chat/Settings/Chats.svelte
+10
-4
src/lib/components/chat/Settings/Connections.svelte
src/lib/components/chat/Settings/Connections.svelte
+80
-37
src/lib/components/chat/Settings/Models.svelte
src/lib/components/chat/Settings/Models.svelte
+326
-290
src/lib/components/common/Tooltip.svelte
src/lib/components/common/Tooltip.svelte
+1
-1
src/lib/components/documents/AddDocModal.svelte
src/lib/components/documents/AddDocModal.svelte
+11
-6
src/lib/components/playground/ChatCompletion.svelte
src/lib/components/playground/ChatCompletion.svelte
+8
-8
src/lib/constants.ts
src/lib/constants.ts
+1
-1
src/routes/(app)/+layout.svelte
src/routes/(app)/+layout.svelte
+15
-11
No files found.
.github/ISSUE_TEMPLATE/bug_report.md
View file @
25e0f0de
...
@@ -32,7 +32,7 @@ assignees: ''
...
@@ -32,7 +32,7 @@ assignees: ''
**Confirmation:**
**Confirmation:**
-
[ ] I have read and followed all the instructions provided in the README.md.
-
[ ] I have read and followed all the instructions provided in the README.md.
-
[ ] I
have reviewed the troubleshooting.md document
.
-
[ ] I
am on the latest version of both Open WebUI and Ollama
.
-
[ ] I have included the browser console logs.
-
[ ] I have included the browser console logs.
-
[ ] I have included the Docker container logs.
-
[ ] I have included the Docker container logs.
...
...
backend/apps/ollama/main.py
View file @
25e0f0de
...
@@ -3,16 +3,23 @@ from fastapi.middleware.cors import CORSMiddleware
...
@@ -3,16 +3,23 @@ from fastapi.middleware.cors import CORSMiddleware
from
fastapi.responses
import
StreamingResponse
from
fastapi.responses
import
StreamingResponse
from
fastapi.concurrency
import
run_in_threadpool
from
fastapi.concurrency
import
run_in_threadpool
from
pydantic
import
BaseModel
,
ConfigDict
import
random
import
requests
import
requests
import
json
import
json
import
uuid
import
uuid
from
pydantic
import
BaseModel
import
aiohttp
import
asyncio
from
apps.web.models.users
import
Users
from
apps.web.models.users
import
Users
from
constants
import
ERROR_MESSAGES
from
constants
import
ERROR_MESSAGES
from
utils.utils
import
decode_token
,
get_current_user
,
get_admin_user
from
utils.utils
import
decode_token
,
get_current_user
,
get_admin_user
from
config
import
OLLAMA_BASE_URL
,
WEBUI_AUTH
from
config
import
OLLAMA_BASE_URL
,
WEBUI_AUTH
from
typing
import
Optional
,
List
,
Union
app
=
FastAPI
()
app
=
FastAPI
()
app
.
add_middleware
(
app
.
add_middleware
(
CORSMiddleware
,
CORSMiddleware
,
...
@@ -23,26 +30,44 @@ app.add_middleware(
...
@@ -23,26 +30,44 @@ app.add_middleware(
)
)
app
.
state
.
OLLAMA_BASE_URL
=
OLLAMA_BASE_URL
app
.
state
.
OLLAMA_BASE_URL
=
OLLAMA_BASE_URL
app
.
state
.
OLLAMA_BASE_URLS
=
[
OLLAMA_BASE_URL
]
# TARGET_SERVER_URL = OLLAMA_API_BASE_URL
app
.
state
.
MODELS
=
{}
REQUEST_POOL
=
[]
REQUEST_POOL
=
[]
@
app
.
get
(
"/url"
)
# TODO: Implement a more intelligent load balancing mechanism for distributing requests among multiple backend instances.
async
def
get_ollama_api_url
(
user
=
Depends
(
get_admin_user
)):
# Current implementation uses a simple round-robin approach (random.choice). Consider incorporating algorithms like weighted round-robin,
return
{
"OLLAMA_BASE_URL"
:
app
.
state
.
OLLAMA_BASE_URL
}
# least connections, or least response time for better resource utilization and performance optimization.
@
app
.
middleware
(
"http"
)
async
def
check_url
(
request
:
Request
,
call_next
):
if
len
(
app
.
state
.
MODELS
)
==
0
:
await
get_all_models
()
else
:
pass
response
=
await
call_next
(
request
)
return
response
@
app
.
get
(
"/urls"
)
async
def
get_ollama_api_urls
(
user
=
Depends
(
get_admin_user
)):
return
{
"OLLAMA_BASE_URLS"
:
app
.
state
.
OLLAMA_BASE_URLS
}
class
UrlUpdateForm
(
BaseModel
):
class
UrlUpdateForm
(
BaseModel
):
url
:
str
url
s
:
List
[
str
]
@
app
.
post
(
"/url/update"
)
@
app
.
post
(
"/url
s
/update"
)
async
def
update_ollama_api_url
(
form_data
:
UrlUpdateForm
,
user
=
Depends
(
get_admin_user
)):
async
def
update_ollama_api_url
(
form_data
:
UrlUpdateForm
,
user
=
Depends
(
get_admin_user
)):
app
.
state
.
OLLAMA_BASE_URL
=
form_data
.
url
app
.
state
.
OLLAMA_BASE_URLS
=
form_data
.
urls
return
{
"OLLAMA_BASE_URL"
:
app
.
state
.
OLLAMA_BASE_URL
}
print
(
app
.
state
.
OLLAMA_BASE_URLS
)
return
{
"OLLAMA_BASE_URLS"
:
app
.
state
.
OLLAMA_BASE_URLS
}
@
app
.
get
(
"/cancel/{request_id}"
)
@
app
.
get
(
"/cancel/{request_id}"
)
...
@@ -55,9 +80,806 @@ async def cancel_ollama_request(request_id: str, user=Depends(get_current_user))
...
@@ -55,9 +80,806 @@ async def cancel_ollama_request(request_id: str, user=Depends(get_current_user))
raise
HTTPException
(
status_code
=
401
,
detail
=
ERROR_MESSAGES
.
ACCESS_PROHIBITED
)
raise
HTTPException
(
status_code
=
401
,
detail
=
ERROR_MESSAGES
.
ACCESS_PROHIBITED
)
async
def
fetch_url
(
url
):
try
:
async
with
aiohttp
.
ClientSession
()
as
session
:
async
with
session
.
get
(
url
)
as
response
:
return
await
response
.
json
()
except
Exception
as
e
:
# Handle connection error here
print
(
f
"Connection error:
{
e
}
"
)
return
None
def
merge_models_lists
(
model_lists
):
merged_models
=
{}
for
idx
,
model_list
in
enumerate
(
model_lists
):
for
model
in
model_list
:
digest
=
model
[
"digest"
]
if
digest
not
in
merged_models
:
model
[
"urls"
]
=
[
idx
]
merged_models
[
digest
]
=
model
else
:
merged_models
[
digest
][
"urls"
].
append
(
idx
)
return
list
(
merged_models
.
values
())
# user=Depends(get_current_user)
async
def
get_all_models
():
print
(
"get_all_models"
)
tasks
=
[
fetch_url
(
f
"
{
url
}
/api/tags"
)
for
url
in
app
.
state
.
OLLAMA_BASE_URLS
]
responses
=
await
asyncio
.
gather
(
*
tasks
)
responses
=
list
(
filter
(
lambda
x
:
x
is
not
None
,
responses
))
models
=
{
"models"
:
merge_models_lists
(
map
(
lambda
response
:
response
[
"models"
],
responses
)
)
}
app
.
state
.
MODELS
=
{
model
[
"model"
]:
model
for
model
in
models
[
"models"
]}
return
models
@
app
.
get
(
"/api/tags"
)
@
app
.
get
(
"/api/tags/{url_idx}"
)
async
def
get_ollama_tags
(
url_idx
:
Optional
[
int
]
=
None
,
user
=
Depends
(
get_current_user
)
):
if
url_idx
==
None
:
return
await
get_all_models
()
else
:
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
try
:
r
=
requests
.
request
(
method
=
"GET"
,
url
=
f
"
{
url
}
/api/tags"
)
r
.
raise_for_status
()
return
r
.
json
()
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
@
app
.
get
(
"/api/version"
)
@
app
.
get
(
"/api/version/{url_idx}"
)
async
def
get_ollama_versions
(
url_idx
:
Optional
[
int
]
=
None
):
if
url_idx
==
None
:
# returns lowest version
tasks
=
[
fetch_url
(
f
"
{
url
}
/api/version"
)
for
url
in
app
.
state
.
OLLAMA_BASE_URLS
]
responses
=
await
asyncio
.
gather
(
*
tasks
)
responses
=
list
(
filter
(
lambda
x
:
x
is
not
None
,
responses
))
lowest_version
=
min
(
responses
,
key
=
lambda
x
:
tuple
(
map
(
int
,
x
[
"version"
].
split
(
"."
)))
)
return
{
"version"
:
lowest_version
[
"version"
]}
else
:
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
try
:
r
=
requests
.
request
(
method
=
"GET"
,
url
=
f
"
{
url
}
/api/version"
)
r
.
raise_for_status
()
return
r
.
json
()
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
class
ModelNameForm
(
BaseModel
):
name
:
str
@
app
.
post
(
"/api/pull"
)
@
app
.
post
(
"/api/pull/{url_idx}"
)
async
def
pull_model
(
form_data
:
ModelNameForm
,
url_idx
:
int
=
0
,
user
=
Depends
(
get_admin_user
)
):
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
r
=
None
def
get_request
():
nonlocal
url
nonlocal
r
try
:
def
stream_content
():
for
chunk
in
r
.
iter_content
(
chunk_size
=
8192
):
yield
chunk
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/api/pull"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
stream
=
True
,
)
r
.
raise_for_status
()
return
StreamingResponse
(
stream_content
(),
status_code
=
r
.
status_code
,
headers
=
dict
(
r
.
headers
),
)
except
Exception
as
e
:
raise
e
try
:
return
await
run_in_threadpool
(
get_request
)
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
class
PushModelForm
(
BaseModel
):
name
:
str
insecure
:
Optional
[
bool
]
=
None
stream
:
Optional
[
bool
]
=
None
@
app
.
delete
(
"/api/push"
)
@
app
.
delete
(
"/api/push/{url_idx}"
)
async
def
push_model
(
form_data
:
PushModelForm
,
url_idx
:
Optional
[
int
]
=
None
,
user
=
Depends
(
get_admin_user
),
):
if
url_idx
==
None
:
if
form_data
.
name
in
app
.
state
.
MODELS
:
url_idx
=
app
.
state
.
MODELS
[
form_data
.
name
][
"urls"
][
0
]
else
:
raise
HTTPException
(
status_code
=
400
,
detail
=
ERROR_MESSAGES
.
MODEL_NOT_FOUND
(
form_data
.
name
),
)
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
r
=
None
def
get_request
():
nonlocal
url
nonlocal
r
try
:
def
stream_content
():
for
chunk
in
r
.
iter_content
(
chunk_size
=
8192
):
yield
chunk
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/api/push"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
)
r
.
raise_for_status
()
return
StreamingResponse
(
stream_content
(),
status_code
=
r
.
status_code
,
headers
=
dict
(
r
.
headers
),
)
except
Exception
as
e
:
raise
e
try
:
return
await
run_in_threadpool
(
get_request
)
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
class
CreateModelForm
(
BaseModel
):
name
:
str
modelfile
:
Optional
[
str
]
=
None
stream
:
Optional
[
bool
]
=
None
path
:
Optional
[
str
]
=
None
@
app
.
post
(
"/api/create"
)
@
app
.
post
(
"/api/create/{url_idx}"
)
async
def
create_model
(
form_data
:
CreateModelForm
,
url_idx
:
int
=
0
,
user
=
Depends
(
get_admin_user
)
):
print
(
form_data
)
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
r
=
None
def
get_request
():
nonlocal
url
nonlocal
r
try
:
def
stream_content
():
for
chunk
in
r
.
iter_content
(
chunk_size
=
8192
):
yield
chunk
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/api/create"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
stream
=
True
,
)
r
.
raise_for_status
()
print
(
r
)
return
StreamingResponse
(
stream_content
(),
status_code
=
r
.
status_code
,
headers
=
dict
(
r
.
headers
),
)
except
Exception
as
e
:
raise
e
try
:
return
await
run_in_threadpool
(
get_request
)
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
class
CopyModelForm
(
BaseModel
):
source
:
str
destination
:
str
@
app
.
post
(
"/api/copy"
)
@
app
.
post
(
"/api/copy/{url_idx}"
)
async
def
copy_model
(
form_data
:
CopyModelForm
,
url_idx
:
Optional
[
int
]
=
None
,
user
=
Depends
(
get_admin_user
),
):
if
url_idx
==
None
:
if
form_data
.
source
in
app
.
state
.
MODELS
:
url_idx
=
app
.
state
.
MODELS
[
form_data
.
source
][
"urls"
][
0
]
else
:
raise
HTTPException
(
status_code
=
400
,
detail
=
ERROR_MESSAGES
.
MODEL_NOT_FOUND
(
form_data
.
source
),
)
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
try
:
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/api/copy"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
)
r
.
raise_for_status
()
print
(
r
.
text
)
return
True
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
@
app
.
delete
(
"/api/delete"
)
@
app
.
delete
(
"/api/delete/{url_idx}"
)
async
def
delete_model
(
form_data
:
ModelNameForm
,
url_idx
:
Optional
[
int
]
=
None
,
user
=
Depends
(
get_admin_user
),
):
if
url_idx
==
None
:
if
form_data
.
name
in
app
.
state
.
MODELS
:
url_idx
=
app
.
state
.
MODELS
[
form_data
.
name
][
"urls"
][
0
]
else
:
raise
HTTPException
(
status_code
=
400
,
detail
=
ERROR_MESSAGES
.
MODEL_NOT_FOUND
(
form_data
.
name
),
)
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
try
:
r
=
requests
.
request
(
method
=
"DELETE"
,
url
=
f
"
{
url
}
/api/delete"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
)
r
.
raise_for_status
()
print
(
r
.
text
)
return
True
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
@
app
.
post
(
"/api/show"
)
async
def
show_model_info
(
form_data
:
ModelNameForm
,
user
=
Depends
(
get_current_user
)):
if
form_data
.
name
not
in
app
.
state
.
MODELS
:
raise
HTTPException
(
status_code
=
400
,
detail
=
ERROR_MESSAGES
.
MODEL_NOT_FOUND
(
form_data
.
name
),
)
url_idx
=
random
.
choice
(
app
.
state
.
MODELS
[
form_data
.
name
][
"urls"
])
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
try
:
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/api/show"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
)
r
.
raise_for_status
()
return
r
.
json
()
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
class
GenerateEmbeddingsForm
(
BaseModel
):
model
:
str
prompt
:
str
options
:
Optional
[
dict
]
=
None
keep_alive
:
Optional
[
Union
[
int
,
str
]]
=
None
@
app
.
post
(
"/api/embeddings"
)
@
app
.
post
(
"/api/embeddings/{url_idx}"
)
async
def
generate_embeddings
(
form_data
:
GenerateEmbeddingsForm
,
url_idx
:
Optional
[
int
]
=
None
,
user
=
Depends
(
get_current_user
),
):
if
url_idx
==
None
:
if
form_data
.
model
in
app
.
state
.
MODELS
:
url_idx
=
random
.
choice
(
app
.
state
.
MODELS
[
form_data
.
model
][
"urls"
])
else
:
raise
HTTPException
(
status_code
=
400
,
detail
=
ERROR_MESSAGES
.
MODEL_NOT_FOUND
(
form_data
.
model
),
)
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
try
:
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/api/embeddings"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
)
r
.
raise_for_status
()
return
r
.
json
()
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
class
GenerateCompletionForm
(
BaseModel
):
model
:
str
prompt
:
str
images
:
Optional
[
List
[
str
]]
=
None
format
:
Optional
[
str
]
=
None
options
:
Optional
[
dict
]
=
None
system
:
Optional
[
str
]
=
None
template
:
Optional
[
str
]
=
None
context
:
Optional
[
str
]
=
None
stream
:
Optional
[
bool
]
=
True
raw
:
Optional
[
bool
]
=
None
keep_alive
:
Optional
[
Union
[
int
,
str
]]
=
None
@
app
.
post
(
"/api/generate"
)
@
app
.
post
(
"/api/generate/{url_idx}"
)
async
def
generate_completion
(
form_data
:
GenerateCompletionForm
,
url_idx
:
Optional
[
int
]
=
None
,
user
=
Depends
(
get_current_user
),
):
if
url_idx
==
None
:
if
form_data
.
model
in
app
.
state
.
MODELS
:
url_idx
=
random
.
choice
(
app
.
state
.
MODELS
[
form_data
.
model
][
"urls"
])
else
:
raise
HTTPException
(
status_code
=
400
,
detail
=
"error_detail"
,
)
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
r
=
None
def
get_request
():
nonlocal
form_data
nonlocal
r
request_id
=
str
(
uuid
.
uuid4
())
try
:
REQUEST_POOL
.
append
(
request_id
)
def
stream_content
():
try
:
if
form_data
.
stream
:
yield
json
.
dumps
({
"id"
:
request_id
,
"done"
:
False
})
+
"
\n
"
for
chunk
in
r
.
iter_content
(
chunk_size
=
8192
):
if
request_id
in
REQUEST_POOL
:
yield
chunk
else
:
print
(
"User: canceled request"
)
break
finally
:
if
hasattr
(
r
,
"close"
):
r
.
close
()
if
request_id
in
REQUEST_POOL
:
REQUEST_POOL
.
remove
(
request_id
)
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/api/generate"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
stream
=
True
,
)
r
.
raise_for_status
()
return
StreamingResponse
(
stream_content
(),
status_code
=
r
.
status_code
,
headers
=
dict
(
r
.
headers
),
)
except
Exception
as
e
:
raise
e
try
:
return
await
run_in_threadpool
(
get_request
)
except
Exception
as
e
:
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
class
ChatMessage
(
BaseModel
):
role
:
str
content
:
str
images
:
Optional
[
List
[
str
]]
=
None
class
GenerateChatCompletionForm
(
BaseModel
):
model
:
str
messages
:
List
[
ChatMessage
]
format
:
Optional
[
str
]
=
None
options
:
Optional
[
dict
]
=
None
template
:
Optional
[
str
]
=
None
stream
:
Optional
[
bool
]
=
True
keep_alive
:
Optional
[
Union
[
int
,
str
]]
=
None
@
app
.
post
(
"/api/chat"
)
@
app
.
post
(
"/api/chat/{url_idx}"
)
async
def
generate_chat_completion
(
form_data
:
GenerateChatCompletionForm
,
url_idx
:
Optional
[
int
]
=
None
,
user
=
Depends
(
get_current_user
),
):
if
url_idx
==
None
:
if
form_data
.
model
in
app
.
state
.
MODELS
:
url_idx
=
random
.
choice
(
app
.
state
.
MODELS
[
form_data
.
model
][
"urls"
])
else
:
raise
HTTPException
(
status_code
=
400
,
detail
=
ERROR_MESSAGES
.
MODEL_NOT_FOUND
(
form_data
.
model
),
)
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
r
=
None
print
(
form_data
.
model_dump_json
(
exclude_none
=
True
))
def
get_request
():
nonlocal
form_data
nonlocal
r
request_id
=
str
(
uuid
.
uuid4
())
try
:
REQUEST_POOL
.
append
(
request_id
)
def
stream_content
():
try
:
if
form_data
.
stream
:
yield
json
.
dumps
({
"id"
:
request_id
,
"done"
:
False
})
+
"
\n
"
for
chunk
in
r
.
iter_content
(
chunk_size
=
8192
):
if
request_id
in
REQUEST_POOL
:
yield
chunk
else
:
print
(
"User: canceled request"
)
break
finally
:
if
hasattr
(
r
,
"close"
):
r
.
close
()
if
request_id
in
REQUEST_POOL
:
REQUEST_POOL
.
remove
(
request_id
)
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/api/chat"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
stream
=
True
,
)
r
.
raise_for_status
()
return
StreamingResponse
(
stream_content
(),
status_code
=
r
.
status_code
,
headers
=
dict
(
r
.
headers
),
)
except
Exception
as
e
:
raise
e
try
:
return
await
run_in_threadpool
(
get_request
)
except
Exception
as
e
:
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
# TODO: we should update this part once Ollama supports other types
class
OpenAIChatMessage
(
BaseModel
):
role
:
str
content
:
str
model_config
=
ConfigDict
(
extra
=
"allow"
)
class
OpenAIChatCompletionForm
(
BaseModel
):
model
:
str
messages
:
List
[
OpenAIChatMessage
]
model_config
=
ConfigDict
(
extra
=
"allow"
)
@
app
.
post
(
"/v1/chat/completions"
)
@
app
.
post
(
"/v1/chat/completions/{url_idx}"
)
async
def
generate_openai_chat_completion
(
form_data
:
OpenAIChatCompletionForm
,
url_idx
:
Optional
[
int
]
=
None
,
user
=
Depends
(
get_current_user
),
):
if
url_idx
==
None
:
if
form_data
.
model
in
app
.
state
.
MODELS
:
url_idx
=
random
.
choice
(
app
.
state
.
MODELS
[
form_data
.
model
][
"urls"
])
else
:
raise
HTTPException
(
status_code
=
400
,
detail
=
ERROR_MESSAGES
.
MODEL_NOT_FOUND
(
form_data
.
model
),
)
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
url_idx
]
print
(
url
)
r
=
None
def
get_request
():
nonlocal
form_data
nonlocal
r
request_id
=
str
(
uuid
.
uuid4
())
try
:
REQUEST_POOL
.
append
(
request_id
)
def
stream_content
():
try
:
if
form_data
.
stream
:
yield
json
.
dumps
(
{
"request_id"
:
request_id
,
"done"
:
False
}
)
+
"
\n
"
for
chunk
in
r
.
iter_content
(
chunk_size
=
8192
):
if
request_id
in
REQUEST_POOL
:
yield
chunk
else
:
print
(
"User: canceled request"
)
break
finally
:
if
hasattr
(
r
,
"close"
):
r
.
close
()
if
request_id
in
REQUEST_POOL
:
REQUEST_POOL
.
remove
(
request_id
)
r
=
requests
.
request
(
method
=
"POST"
,
url
=
f
"
{
url
}
/v1/chat/completions"
,
data
=
form_data
.
model_dump_json
(
exclude_none
=
True
),
stream
=
True
,
)
r
.
raise_for_status
()
return
StreamingResponse
(
stream_content
(),
status_code
=
r
.
status_code
,
headers
=
dict
(
r
.
headers
),
)
except
Exception
as
e
:
raise
e
try
:
return
await
run_in_threadpool
(
get_request
)
except
Exception
as
e
:
error_detail
=
"Open WebUI: Server Connection Error"
if
r
is
not
None
:
try
:
res
=
r
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
raise
HTTPException
(
status_code
=
r
.
status_code
if
r
else
500
,
detail
=
error_detail
,
)
@
app
.
api_route
(
"/{path:path}"
,
methods
=
[
"GET"
,
"POST"
,
"PUT"
,
"DELETE"
])
@
app
.
api_route
(
"/{path:path}"
,
methods
=
[
"GET"
,
"POST"
,
"PUT"
,
"DELETE"
])
async
def
proxy
(
path
:
str
,
request
:
Request
,
user
=
Depends
(
get_current_user
)):
async
def
deprecated_proxy
(
path
:
str
,
request
:
Request
,
user
=
Depends
(
get_current_user
)):
target_url
=
f
"
{
app
.
state
.
OLLAMA_BASE_URL
}
/
{
path
}
"
url
=
app
.
state
.
OLLAMA_BASE_URLS
[
0
]
target_url
=
f
"
{
url
}
/
{
path
}
"
body
=
await
request
.
body
()
body
=
await
request
.
body
()
headers
=
dict
(
request
.
headers
)
headers
=
dict
(
request
.
headers
)
...
...
backend/apps/ollama/old_main.py
deleted
100644 → 0
View file @
df8aeb39
from
fastapi
import
FastAPI
,
Request
,
Response
,
HTTPException
,
Depends
from
fastapi.middleware.cors
import
CORSMiddleware
from
fastapi.responses
import
StreamingResponse
import
requests
import
json
from
pydantic
import
BaseModel
from
apps.web.models.users
import
Users
from
constants
import
ERROR_MESSAGES
from
utils.utils
import
decode_token
,
get_current_user
from
config
import
OLLAMA_API_BASE_URL
,
WEBUI_AUTH
import
aiohttp
app
=
FastAPI
()
app
.
add_middleware
(
CORSMiddleware
,
allow_origins
=
[
"*"
],
allow_credentials
=
True
,
allow_methods
=
[
"*"
],
allow_headers
=
[
"*"
],
)
app
.
state
.
OLLAMA_API_BASE_URL
=
OLLAMA_API_BASE_URL
# TARGET_SERVER_URL = OLLAMA_API_BASE_URL
@
app
.
get
(
"/url"
)
async
def
get_ollama_api_url
(
user
=
Depends
(
get_current_user
)):
if
user
and
user
.
role
==
"admin"
:
return
{
"OLLAMA_API_BASE_URL"
:
app
.
state
.
OLLAMA_API_BASE_URL
}
else
:
raise
HTTPException
(
status_code
=
401
,
detail
=
ERROR_MESSAGES
.
ACCESS_PROHIBITED
)
class
UrlUpdateForm
(
BaseModel
):
url
:
str
@
app
.
post
(
"/url/update"
)
async
def
update_ollama_api_url
(
form_data
:
UrlUpdateForm
,
user
=
Depends
(
get_current_user
)
):
if
user
and
user
.
role
==
"admin"
:
app
.
state
.
OLLAMA_API_BASE_URL
=
form_data
.
url
return
{
"OLLAMA_API_BASE_URL"
:
app
.
state
.
OLLAMA_API_BASE_URL
}
else
:
raise
HTTPException
(
status_code
=
401
,
detail
=
ERROR_MESSAGES
.
ACCESS_PROHIBITED
)
# async def fetch_sse(method, target_url, body, headers):
# async with aiohttp.ClientSession() as session:
# try:
# async with session.request(
# method, target_url, data=body, headers=headers
# ) as response:
# print(response.status)
# async for line in response.content:
# yield line
# except Exception as e:
# print(e)
# error_detail = "Open WebUI: Server Connection Error"
# yield json.dumps({"error": error_detail, "message": str(e)}).encode()
@
app
.
api_route
(
"/{path:path}"
,
methods
=
[
"GET"
,
"POST"
,
"PUT"
,
"DELETE"
])
async
def
proxy
(
path
:
str
,
request
:
Request
,
user
=
Depends
(
get_current_user
)):
target_url
=
f
"
{
app
.
state
.
OLLAMA_API_BASE_URL
}
/
{
path
}
"
print
(
target_url
)
body
=
await
request
.
body
()
headers
=
dict
(
request
.
headers
)
if
user
.
role
in
[
"user"
,
"admin"
]:
if
path
in
[
"pull"
,
"delete"
,
"push"
,
"copy"
,
"create"
]:
if
user
.
role
!=
"admin"
:
raise
HTTPException
(
status_code
=
401
,
detail
=
ERROR_MESSAGES
.
ACCESS_PROHIBITED
)
else
:
raise
HTTPException
(
status_code
=
401
,
detail
=
ERROR_MESSAGES
.
ACCESS_PROHIBITED
)
headers
.
pop
(
"Host"
,
None
)
headers
.
pop
(
"Authorization"
,
None
)
headers
.
pop
(
"Origin"
,
None
)
headers
.
pop
(
"Referer"
,
None
)
session
=
aiohttp
.
ClientSession
()
response
=
None
try
:
response
=
await
session
.
request
(
request
.
method
,
target_url
,
data
=
body
,
headers
=
headers
)
print
(
response
)
if
not
response
.
ok
:
data
=
await
response
.
json
()
print
(
data
)
response
.
raise_for_status
()
async
def
generate
():
async
for
line
in
response
.
content
:
print
(
line
)
yield
line
await
session
.
close
()
return
StreamingResponse
(
generate
(),
response
.
status
)
except
Exception
as
e
:
print
(
e
)
error_detail
=
"Open WebUI: Server Connection Error"
if
response
is
not
None
:
try
:
res
=
await
response
.
json
()
if
"error"
in
res
:
error_detail
=
f
"Ollama:
{
res
[
'error'
]
}
"
except
:
error_detail
=
f
"Ollama:
{
e
}
"
await
session
.
close
()
raise
HTTPException
(
status_code
=
response
.
status
if
response
else
500
,
detail
=
error_detail
,
)
backend/apps/rag/main.py
View file @
25e0f0de
...
@@ -108,7 +108,7 @@ class StoreWebForm(CollectionNameForm):
...
@@ -108,7 +108,7 @@ class StoreWebForm(CollectionNameForm):
url
:
str
url
:
str
def
store_data_in_vector_db
(
data
,
collection_name
)
->
bool
:
def
store_data_in_vector_db
(
data
,
collection_name
,
overwrite
:
bool
=
False
)
->
bool
:
text_splitter
=
RecursiveCharacterTextSplitter
(
text_splitter
=
RecursiveCharacterTextSplitter
(
chunk_size
=
app
.
state
.
CHUNK_SIZE
,
chunk_overlap
=
app
.
state
.
CHUNK_OVERLAP
chunk_size
=
app
.
state
.
CHUNK_SIZE
,
chunk_overlap
=
app
.
state
.
CHUNK_OVERLAP
)
)
...
@@ -118,6 +118,12 @@ def store_data_in_vector_db(data, collection_name) -> bool:
...
@@ -118,6 +118,12 @@ def store_data_in_vector_db(data, collection_name) -> bool:
metadatas
=
[
doc
.
metadata
for
doc
in
docs
]
metadatas
=
[
doc
.
metadata
for
doc
in
docs
]
try
:
try
:
if
overwrite
:
for
collection
in
CHROMA_CLIENT
.
list_collections
():
if
collection_name
==
collection
.
name
:
print
(
f
"deleting existing collection
{
collection_name
}
"
)
CHROMA_CLIENT
.
delete_collection
(
name
=
collection_name
)
collection
=
CHROMA_CLIENT
.
create_collection
(
collection
=
CHROMA_CLIENT
.
create_collection
(
name
=
collection_name
,
name
=
collection_name
,
embedding_function
=
app
.
state
.
sentence_transformer_ef
,
embedding_function
=
app
.
state
.
sentence_transformer_ef
,
...
@@ -355,7 +361,7 @@ def store_web(form_data: StoreWebForm, user=Depends(get_current_user)):
...
@@ -355,7 +361,7 @@ def store_web(form_data: StoreWebForm, user=Depends(get_current_user)):
if
collection_name
==
""
:
if
collection_name
==
""
:
collection_name
=
calculate_sha256_string
(
form_data
.
url
)[:
63
]
collection_name
=
calculate_sha256_string
(
form_data
.
url
)[:
63
]
store_data_in_vector_db
(
data
,
collection_name
)
store_data_in_vector_db
(
data
,
collection_name
,
overwrite
=
True
)
return
{
return
{
"status"
:
True
,
"status"
:
True
,
"collection_name"
:
collection_name
,
"collection_name"
:
collection_name
,
...
...
backend/constants.py
View file @
25e0f0de
...
@@ -48,3 +48,5 @@ class ERROR_MESSAGES(str, Enum):
...
@@ -48,3 +48,5 @@ class ERROR_MESSAGES(str, Enum):
lambda
err
=
""
:
f
"Invalid format. Please use the correct format
{
err
if
err
else
''
}
"
lambda
err
=
""
:
f
"Invalid format. Please use the correct format
{
err
if
err
else
''
}
"
)
)
RATE_LIMIT_EXCEEDED
=
"API rate limit exceeded"
RATE_LIMIT_EXCEEDED
=
"API rate limit exceeded"
MODEL_NOT_FOUND
=
lambda
name
=
""
:
f
"Model '
{
name
}
' was not found"
backend/main.py
View file @
25e0f0de
...
@@ -104,7 +104,7 @@ async def auth_middleware(request: Request, call_next):
...
@@ -104,7 +104,7 @@ async def auth_middleware(request: Request, call_next):
app
.
mount
(
"/api/v1"
,
webui_app
)
app
.
mount
(
"/api/v1"
,
webui_app
)
app
.
mount
(
"/litellm/api"
,
litellm_app
)
app
.
mount
(
"/litellm/api"
,
litellm_app
)
app
.
mount
(
"/ollama
/api
"
,
ollama_app
)
app
.
mount
(
"/ollama"
,
ollama_app
)
app
.
mount
(
"/openai/api"
,
openai_app
)
app
.
mount
(
"/openai/api"
,
openai_app
)
app
.
mount
(
"/images/api/v1"
,
images_app
)
app
.
mount
(
"/images/api/v1"
,
images_app
)
...
@@ -125,6 +125,14 @@ async def get_app_config():
...
@@ -125,6 +125,14 @@ async def get_app_config():
}
}
@
app
.
get
(
"/api/version"
)
async
def
get_app_config
():
return
{
"version"
:
VERSION
,
}
@
app
.
get
(
"/api/changelog"
)
@
app
.
get
(
"/api/changelog"
)
async
def
get_app_changelog
():
async
def
get_app_changelog
():
return
CHANGELOG
return
CHANGELOG
...
...
backend/requirements.txt
View file @
25e0f0de
...
@@ -22,6 +22,7 @@ google-generativeai
...
@@ -22,6 +22,7 @@ google-generativeai
langchain
langchain
langchain-community
langchain-community
fake_useragent
chromadb
chromadb
sentence_transformers
sentence_transformers
pypdf
pypdf
...
...
src/lib/apis/ollama/index.ts
View file @
25e0f0de
import
{
OLLAMA_API_BASE_URL
}
from
'
$lib/constants
'
;
import
{
OLLAMA_API_BASE_URL
}
from
'
$lib/constants
'
;
export
const
getOllama
API
Url
=
async
(
token
:
string
=
''
)
=>
{
export
const
getOllamaUrl
s
=
async
(
token
:
string
=
''
)
=>
{
let
error
=
null
;
let
error
=
null
;
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/url`
,
{
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/url
s
`
,
{
method
:
'
GET
'
,
method
:
'
GET
'
,
headers
:
{
headers
:
{
Accept
:
'
application/json
'
,
Accept
:
'
application/json
'
,
...
@@ -29,13 +29,13 @@ export const getOllamaAPIUrl = async (token: string = '') => {
...
@@ -29,13 +29,13 @@ export const getOllamaAPIUrl = async (token: string = '') => {
throw
error
;
throw
error
;
}
}
return
res
.
OLLAMA_BASE_URL
;
return
res
.
OLLAMA_BASE_URL
S
;
};
};
export
const
updateOllama
API
Url
=
async
(
token
:
string
=
''
,
url
:
string
)
=>
{
export
const
updateOllamaUrl
s
=
async
(
token
:
string
=
''
,
url
s
:
string
[]
)
=>
{
let
error
=
null
;
let
error
=
null
;
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/url/update`
,
{
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/url
s
/update`
,
{
method
:
'
POST
'
,
method
:
'
POST
'
,
headers
:
{
headers
:
{
Accept
:
'
application/json
'
,
Accept
:
'
application/json
'
,
...
@@ -43,7 +43,7 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
...
@@ -43,7 +43,7 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
...(
token
&&
{
authorization
:
`Bearer
${
token
}
`
})
...(
token
&&
{
authorization
:
`Bearer
${
token
}
`
})
},
},
body
:
JSON
.
stringify
({
body
:
JSON
.
stringify
({
url
:
url
url
s
:
url
s
})
})
})
})
.
then
(
async
(
res
)
=>
{
.
then
(
async
(
res
)
=>
{
...
@@ -64,7 +64,7 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
...
@@ -64,7 +64,7 @@ export const updateOllamaAPIUrl = async (token: string = '', url: string) => {
throw
error
;
throw
error
;
}
}
return
res
.
OLLAMA_BASE_URL
;
return
res
.
OLLAMA_BASE_URL
S
;
};
};
export
const
getOllamaVersion
=
async
(
token
:
string
=
''
)
=>
{
export
const
getOllamaVersion
=
async
(
token
:
string
=
''
)
=>
{
...
@@ -151,7 +151,8 @@ export const generateTitle = async (
...
@@ -151,7 +151,8 @@ export const generateTitle = async (
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/generate`
,
{
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/generate`
,
{
method
:
'
POST
'
,
method
:
'
POST
'
,
headers
:
{
headers
:
{
'
Content-Type
'
:
'
text/event-stream
'
,
Accept
:
'
application/json
'
,
'
Content-Type
'
:
'
application/json
'
,
Authorization
:
`Bearer
${
token
}
`
Authorization
:
`Bearer
${
token
}
`
},
},
body
:
JSON
.
stringify
({
body
:
JSON
.
stringify
({
...
@@ -189,7 +190,8 @@ export const generatePrompt = async (token: string = '', model: string, conversa
...
@@ -189,7 +190,8 @@ export const generatePrompt = async (token: string = '', model: string, conversa
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/generate`
,
{
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/generate`
,
{
method
:
'
POST
'
,
method
:
'
POST
'
,
headers
:
{
headers
:
{
'
Content-Type
'
:
'
text/event-stream
'
,
Accept
:
'
application/json
'
,
'
Content-Type
'
:
'
application/json
'
,
Authorization
:
`Bearer
${
token
}
`
Authorization
:
`Bearer
${
token
}
`
},
},
body
:
JSON
.
stringify
({
body
:
JSON
.
stringify
({
...
@@ -223,7 +225,8 @@ export const generateTextCompletion = async (token: string = '', model: string,
...
@@ -223,7 +225,8 @@ export const generateTextCompletion = async (token: string = '', model: string,
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/generate`
,
{
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/generate`
,
{
method
:
'
POST
'
,
method
:
'
POST
'
,
headers
:
{
headers
:
{
'
Content-Type
'
:
'
text/event-stream
'
,
Accept
:
'
application/json
'
,
'
Content-Type
'
:
'
application/json
'
,
Authorization
:
`Bearer
${
token
}
`
Authorization
:
`Bearer
${
token
}
`
},
},
body
:
JSON
.
stringify
({
body
:
JSON
.
stringify
({
...
@@ -251,7 +254,8 @@ export const generateChatCompletion = async (token: string = '', body: object) =
...
@@ -251,7 +254,8 @@ export const generateChatCompletion = async (token: string = '', body: object) =
signal
:
controller
.
signal
,
signal
:
controller
.
signal
,
method
:
'
POST
'
,
method
:
'
POST
'
,
headers
:
{
headers
:
{
'
Content-Type
'
:
'
text/event-stream
'
,
Accept
:
'
application/json
'
,
'
Content-Type
'
:
'
application/json
'
,
Authorization
:
`Bearer
${
token
}
`
Authorization
:
`Bearer
${
token
}
`
},
},
body
:
JSON
.
stringify
(
body
)
body
:
JSON
.
stringify
(
body
)
...
@@ -294,7 +298,8 @@ export const createModel = async (token: string, tagName: string, content: strin
...
@@ -294,7 +298,8 @@ export const createModel = async (token: string, tagName: string, content: strin
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/create`
,
{
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/create`
,
{
method
:
'
POST
'
,
method
:
'
POST
'
,
headers
:
{
headers
:
{
'
Content-Type
'
:
'
text/event-stream
'
,
Accept
:
'
application/json
'
,
'
Content-Type
'
:
'
application/json
'
,
Authorization
:
`Bearer
${
token
}
`
Authorization
:
`Bearer
${
token
}
`
},
},
body
:
JSON
.
stringify
({
body
:
JSON
.
stringify
({
...
@@ -313,19 +318,23 @@ export const createModel = async (token: string, tagName: string, content: strin
...
@@ -313,19 +318,23 @@ export const createModel = async (token: string, tagName: string, content: strin
return
res
;
return
res
;
};
};
export
const
deleteModel
=
async
(
token
:
string
,
tagName
:
string
)
=>
{
export
const
deleteModel
=
async
(
token
:
string
,
tagName
:
string
,
urlIdx
:
string
|
null
=
null
)
=>
{
let
error
=
null
;
let
error
=
null
;
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/delete`
,
{
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/delete
${
urlIdx
!==
null
?
`/
${
urlIdx
}
`
:
''
}
`
,
{
method
:
'
DELETE
'
,
method
:
'
DELETE
'
,
headers
:
{
headers
:
{
'
Content-Type
'
:
'
text/event-stream
'
,
Accept
:
'
application/json
'
,
'
Content-Type
'
:
'
application/json
'
,
Authorization
:
`Bearer
${
token
}
`
Authorization
:
`Bearer
${
token
}
`
},
},
body
:
JSON
.
stringify
({
body
:
JSON
.
stringify
({
name
:
tagName
name
:
tagName
})
})
})
}
)
.
then
(
async
(
res
)
=>
{
.
then
(
async
(
res
)
=>
{
if
(
!
res
.
ok
)
throw
await
res
.
json
();
if
(
!
res
.
ok
)
throw
await
res
.
json
();
return
res
.
json
();
return
res
.
json
();
...
@@ -336,7 +345,12 @@ export const deleteModel = async (token: string, tagName: string) => {
...
@@ -336,7 +345,12 @@ export const deleteModel = async (token: string, tagName: string) => {
})
})
.
catch
((
err
)
=>
{
.
catch
((
err
)
=>
{
console
.
log
(
err
);
console
.
log
(
err
);
error
=
err
.
error
;
error
=
err
;
if
(
'
detail
'
in
err
)
{
error
=
err
.
detail
;
}
return
null
;
return
null
;
});
});
...
@@ -347,13 +361,14 @@ export const deleteModel = async (token: string, tagName: string) => {
...
@@ -347,13 +361,14 @@ export const deleteModel = async (token: string, tagName: string) => {
return
res
;
return
res
;
};
};
export
const
pullModel
=
async
(
token
:
string
,
tagName
:
string
)
=>
{
export
const
pullModel
=
async
(
token
:
string
,
tagName
:
string
,
urlIdx
:
string
|
null
=
null
)
=>
{
let
error
=
null
;
let
error
=
null
;
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/pull`
,
{
const
res
=
await
fetch
(
`
${
OLLAMA_API_BASE_URL
}
/api/pull
${
urlIdx
!==
null
?
`/
${
urlIdx
}
`
:
''
}
`
,
{
method
:
'
POST
'
,
method
:
'
POST
'
,
headers
:
{
headers
:
{
'
Content-Type
'
:
'
text/event-stream
'
,
Accept
:
'
application/json
'
,
'
Content-Type
'
:
'
application/json
'
,
Authorization
:
`Bearer
${
token
}
`
Authorization
:
`Bearer
${
token
}
`
},
},
body
:
JSON
.
stringify
({
body
:
JSON
.
stringify
({
...
...
src/lib/components/chat/MessageInput.svelte
View file @
25e0f0de
...
@@ -21,7 +21,7 @@
...
@@ -21,7 +21,7 @@
export let suggestionPrompts = [];
export let suggestionPrompts = [];
export let autoScroll = true;
export let autoScroll = true;
let chatTextAreaElement:HTMLTextAreaElement
let filesInputElement;
let filesInputElement;
let promptsElement;
let promptsElement;
...
@@ -45,11 +45,9 @@
...
@@ -45,11 +45,9 @@
let speechRecognition;
let speechRecognition;
$: if (prompt) {
$: if (prompt) {
const chatInput = document.getElementById('chat-textarea');
if (chatTextAreaElement) {
chatTextAreaElement.style.height = '';
if (chatInput) {
chatTextAreaElement.style.height = Math.min(chatTextAreaElement.scrollHeight, 200) + 'px';
chatInput.style.height = '';
chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
}
}
}
}
...
@@ -88,9 +86,7 @@
...
@@ -88,9 +86,7 @@
if (res) {
if (res) {
prompt = res.text;
prompt = res.text;
await tick();
await tick();
chatTextAreaElement?.focus();
const inputElement = document.getElementById('chat-textarea');
inputElement?.focus();
if (prompt !== '' && $settings?.speechAutoSend === true) {
if (prompt !== '' && $settings?.speechAutoSend === true) {
submitPrompt(prompt, user);
submitPrompt(prompt, user);
...
@@ -193,8 +189,7 @@
...
@@ -193,8 +189,7 @@
prompt = `${prompt}${transcript}`;
prompt = `${prompt}${transcript}`;
await tick();
await tick();
const inputElement = document.getElementById('chat-textarea');
chatTextAreaElement?.focus();
inputElement?.focus();
// Restart the inactivity timeout
// Restart the inactivity timeout
timeoutId = setTimeout(() => {
timeoutId = setTimeout(() => {
...
@@ -296,8 +291,7 @@
...
@@ -296,8 +291,7 @@
};
};
onMount(() => {
onMount(() => {
const chatInput = document.getElementById('chat-textarea');
window.setTimeout(() => chatTextAreaElement?.focus(), 0);
window.setTimeout(() => chatInput?.focus(), 0);
const dropZone = document.querySelector('body');
const dropZone = document.querySelector('body');
...
@@ -671,6 +665,7 @@
...
@@ -671,6 +665,7 @@
<textarea
<textarea
id="chat-textarea"
id="chat-textarea"
bind:this={chatTextAreaElement}
class=" dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled
class=" dark:bg-gray-900 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled
? ''
? ''
: ' pl-4'} rounded-xl resize-none h-[48px]"
: ' pl-4'} rounded-xl resize-none h-[48px]"
...
...
src/lib/components/chat/Messages/ResponseMessage.svelte
View file @
25e0f0de
...
@@ -42,7 +42,7 @@
...
@@ -42,7 +42,7 @@
let edit = false;
let edit = false;
let editedContent = '';
let editedContent = '';
let editTextAreaElement: HTMLTextAreaElement;
let tooltipInstance = null;
let tooltipInstance = null;
let sentencesAudio = {};
let sentencesAudio = {};
...
@@ -249,10 +249,9 @@
...
@@ -249,10 +249,9 @@
editedContent = message.content;
editedContent = message.content;
await tick();
await tick();
const editElement = document.getElementById(`message-edit-${message.id}`);
editElement.style.height = '';
edit
TextArea
Element.style.height = '';
editElement.style.height = `${editElement.scrollHeight}px`;
edit
TextArea
Element.style.height = `${edit
TextArea
Element.scrollHeight}px`;
};
};
const editMessageConfirmHandler = async () => {
const editMessageConfirmHandler = async () => {
...
@@ -343,6 +342,7 @@
...
@@ -343,6 +342,7 @@
<div class=" w-full">
<div class=" w-full">
<textarea
<textarea
id="message-edit-{message.id}"
id="message-edit-{message.id}"
bind:this={editTextAreaElement}
class=" bg-transparent outline-none w-full resize-none"
class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent}
bind:value={editedContent}
on:input={(e) => {
on:input={(e) => {
...
...
src/lib/components/chat/Messages/UserMessage.svelte
View file @
25e0f0de
...
@@ -22,18 +22,17 @@
...
@@ -22,18 +22,17 @@
let edit = false;
let edit = false;
let editedContent = '';
let editedContent = '';
let messageEditTextAreaElement: HTMLTextAreaElement;
const editMessageHandler = async () => {
const editMessageHandler = async () => {
edit = true;
edit = true;
editedContent = message.content;
editedContent = message.content;
await tick();
await tick();
const editElement = document.getElementById(`message-edit-${message.id}`);
edit
Element.style.height = '';
messageEditTextArea
Element.style.height = '';
edit
Element.style.height = `${
edit
Element.scrollHeight}px`;
messageEditTextArea
Element.style.height = `${
messageEditTextArea
Element.scrollHeight}px`;
edit
Element?.focus();
messageEditTextArea
Element?.focus();
};
};
const editMessageConfirmHandler = async () => {
const editMessageConfirmHandler = async () => {
...
@@ -168,10 +167,11 @@
...
@@ -168,10 +167,11 @@
<div class=" w-full">
<div class=" w-full">
<textarea
<textarea
id="message-edit-{message.id}"
id="message-edit-{message.id}"
bind:this={messageEditTextAreaElement}
class=" bg-transparent outline-none w-full resize-none"
class=" bg-transparent outline-none w-full resize-none"
bind:value={editedContent}
bind:value={editedContent}
on:input={(e) => {
on:input={(e) => {
e.target.style.height = `${e.targe
t.scrollHeight}px`;
messageEditTextAreaElement.style.height = `${messageEditTextAreaElemen
t.scrollHeight}px`;
}}
}}
/>
/>
...
...
src/lib/components/chat/Settings/Account.svelte
View file @
25e0f0de
...
@@ -17,6 +17,7 @@
...
@@ -17,6 +17,7 @@
let name = '';
let name = '';
let showJWTToken = false;
let showJWTToken = false;
let JWTTokenCopied = false;
let JWTTokenCopied = false;
let profileImageInputElement: HTMLInputElement;
const submitHandler = async () => {
const submitHandler = async () => {
const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
...
@@ -42,11 +43,12 @@
...
@@ -42,11 +43,12 @@
<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-80">
<input
<input
id="profile-image-input"
id="profile-image-input"
bind:this={profileImageInputElement}
type="file"
type="file"
hidden
hidden
accept="image/*"
accept="image/*"
on:change={(e) => {
on:change={(e) => {
const files =
e?.target?
.files ?? [];
const files =
profileImageInputElement
.files ?? [];
let reader = new FileReader();
let reader = new FileReader();
reader.onload = (event) => {
reader.onload = (event) => {
let originalImageUrl = `${event.target.result}`;
let originalImageUrl = `${event.target.result}`;
...
@@ -88,7 +90,7 @@
...
@@ -88,7 +90,7 @@
// Display the compressed image
// Display the compressed image
profileImageUrl = compressedSrc;
profileImageUrl = compressedSrc;
e.targe
t.files = null;
profileImageInputElemen
t.files = null;
};
};
};
};
...
@@ -109,9 +111,7 @@
...
@@ -109,9 +111,7 @@
<button
<button
class="relative rounded-full dark:bg-gray-700"
class="relative rounded-full dark:bg-gray-700"
type="button"
type="button"
on:click={() => {
on:click={profileImageInputElement.click}
document.getElementById('profile-image-input')?.click();
}}
>
>
<img
<img
src={profileImageUrl !== '' ? profileImageUrl : '/user.png'}
src={profileImageUrl !== '' ? profileImageUrl : '/user.png'}
...
...
src/lib/components/chat/Settings/Chats.svelte
View file @
25e0f0de
...
@@ -24,6 +24,7 @@
...
@@ -24,6 +24,7 @@
let saveChatHistory = true;
let saveChatHistory = true;
let importFiles;
let importFiles;
let showDeleteConfirm = false;
let showDeleteConfirm = false;
let chatImportInputElement: HTMLInputElement;
$: if (importFiles) {
$: if (importFiles) {
console.log(importFiles);
console.log(importFiles);
...
@@ -161,12 +162,17 @@
...
@@ -161,12 +162,17 @@
<hr class=" dark:border-gray-700" />
<hr class=" dark:border-gray-700" />
<div class="flex flex-col">
<div class="flex flex-col">
<input id="chat-import-input" bind:files={importFiles} type="file" accept=".json" hidden />
<input
id="chat-import-input"
bind:this={chatImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
/>
<button
<button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
on:click={chatImportInputElement.click}
document.getElementById('chat-import-input').click();
}}
>
>
<div class=" self-center mr-3">
<div class=" self-center mr-3">
<svg
<svg
...
...
src/lib/components/chat/Settings/Connections.svelte
View file @
25e0f0de
...
@@ -3,7 +3,7 @@
...
@@ -3,7 +3,7 @@
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { createEventDispatcher, onMount, getContext } from 'svelte';
const dispatch = createEventDispatcher();
const dispatch = createEventDispatcher();
import { getOllama
API
Url, getOllamaVersion, updateOllama
API
Url } from '$lib/apis/ollama';
import { getOllamaUrl
s
, getOllamaVersion, updateOllamaUrl
s
} from '$lib/apis/ollama';
import { getOpenAIKey, getOpenAIUrl, updateOpenAIKey, updateOpenAIUrl } from '$lib/apis/openai';
import { getOpenAIKey, getOpenAIUrl, updateOpenAIKey, updateOpenAIUrl } from '$lib/apis/openai';
import { toast } from 'svelte-sonner';
import { toast } from 'svelte-sonner';
...
@@ -12,7 +12,8 @@
...
@@ -12,7 +12,8 @@
export let getModels: Function;
export let getModels: Function;
// External
// External
let API_BASE_URL = '';
let OLLAMA_BASE_URL = '';
let OLLAMA_BASE_URLS = [''];
let OPENAI_API_KEY = '';
let OPENAI_API_KEY = '';
let OPENAI_API_BASE_URL = '';
let OPENAI_API_BASE_URL = '';
...
@@ -27,8 +28,8 @@
...
@@ -27,8 +28,8 @@
await models.set(await getModels());
await models.set(await getModels());
};
};
const updateOllama
API
UrlHandler = async () => {
const updateOllamaUrl
s
Handler = async () => {
API
_BASE_URL = await updateOllama
API
Url(localStorage.token,
API
_BASE_URL);
OLLAMA
_BASE_URL
S
= await updateOllamaUrl
s
(localStorage.token,
OLLAMA
_BASE_URL
S
);
const ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => {
const ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => {
toast.error(error);
toast.error(error);
...
@@ -43,7 +44,7 @@
...
@@ -43,7 +44,7 @@
onMount(async () => {
onMount(async () => {
if ($user.role === 'admin') {
if ($user.role === 'admin') {
API
_BASE_URL = await getOllama
API
Url(localStorage.token);
OLLAMA
_BASE_URL
S
= await getOllamaUrl
s
(localStorage.token);
OPENAI_API_BASE_URL = await getOpenAIUrl(localStorage.token);
OPENAI_API_BASE_URL = await getOpenAIUrl(localStorage.token);
OPENAI_API_KEY = await getOpenAIKey(localStorage.token);
OPENAI_API_KEY = await getOpenAIKey(localStorage.token);
}
}
...
@@ -55,11 +56,6 @@
...
@@ -55,11 +56,6 @@
on:submit|preventDefault={() => {
on:submit|preventDefault={() => {
updateOpenAIHandler();
updateOpenAIHandler();
dispatch('save');
dispatch('save');
// saveSettings({
// OPENAI_API_KEY: OPENAI_API_KEY !== '' ? OPENAI_API_KEY : undefined,
// OPENAI_API_BASE_URL: OPENAI_API_BASE_URL !== '' ? OPENAI_API_BASE_URL : undefined
// });
}}
}}
>
>
<div class=" pr-1.5 overflow-y-scroll max-h-[20.5rem] space-y-3">
<div class=" pr-1.5 overflow-y-scroll max-h-[20.5rem] space-y-3">
...
@@ -116,19 +112,65 @@
...
@@ -116,19 +112,65 @@
<hr class=" dark:border-gray-700" />
<hr class=" dark:border-gray-700" />
<div>
<div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Ollama API URL')}</div>
<div class=" mb-2.5 text-sm font-medium">Ollama Base URL</div>
<div class="flex w-full">
<div class="flex w-full gap-1.5">
<div class="flex-1 mr-2">
<div class="flex-1 flex flex-col gap-2">
{#each OLLAMA_BASE_URLS as url, idx}
<div class="flex gap-1.5">
<input
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-8
0
0 outline-none"
class="w-full rounded
-lg
py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-8
5
0 outline-none"
placeholder="Enter URL (e.g. http://localhost:11434)"
placeholder="Enter URL (e.g. http://localhost:11434)"
bind:value={API_BASE_URL}
bind:value={url}
/>
<div class="self-center flex items-center">
{#if idx === 0}
<button
class="px-1"
on:click={() => {
OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, ''];
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
/>
/>
</svg>
</button>
{:else}
<button
class="px-1"
on:click={() => {
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path d="M3.75 7.25a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z" />
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
<div class="">
<button
<button
class="p
x-3
bg-gray-200 hover:bg-gray-300 dark:bg-gray-
60
0 dark:hover:bg-gray-
7
00 rounded transition"
class="p
-2.5
bg-gray-200 hover:bg-gray-300 dark:bg-gray-
85
0 dark:hover:bg-gray-
8
00 rounded
-lg
transition"
on:click={() => {
on:click={() => {
updateOllama
API
UrlHandler();
updateOllamaUrl
s
Handler();
}}
}}
type="button"
type="button"
>
>
...
@@ -146,6 +188,7 @@
...
@@ -146,6 +188,7 @@
</svg>
</svg>
</button>
</button>
</div>
</div>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('Trouble accessing Ollama?')}
{$i18n.t('Trouble accessing Ollama?')}
...
...
src/lib/components/chat/Settings/Models.svelte
View file @
25e0f0de
...
@@ -2,7 +2,13 @@
...
@@ -2,7 +2,13 @@
import queue from 'async/queue';
import queue from 'async/queue';
import { toast } from 'svelte-sonner';
import { toast } from 'svelte-sonner';
import { createModel, deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
import {
createModel,
deleteModel,
getOllamaUrls,
getOllamaVersion,
pullModel
} from '$lib/apis/ollama';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, models, user } from '$lib/stores';
import { WEBUI_NAME, models, user } from '$lib/stores';
import { splitStream } from '$lib/utils';
import { splitStream } from '$lib/utils';
...
@@ -15,7 +21,7 @@
...
@@ -15,7 +21,7 @@
let showLiteLLM = false;
let showLiteLLM = false;
let showLiteLLMParams = false;
let showLiteLLMParams = false;
let modelUploadInputElement: HTMLInputElement;
let liteLLMModelInfo = [];
let liteLLMModelInfo = [];
let liteLLMModel = '';
let liteLLMModel = '';
...
@@ -29,6 +35,9 @@
...
@@ -29,6 +35,9 @@
$: liteLLMModelName = liteLLMModel;
$: liteLLMModelName = liteLLMModel;
// Models
// Models
let OLLAMA_URLS = [];
let selectedOllamaUrlIdx: string | null = null;
let showExperimentalOllama = false;
let showExperimentalOllama = false;
let ollamaVersion = '';
let ollamaVersion = '';
const MAX_PARALLEL_DOWNLOADS = 3;
const MAX_PARALLEL_DOWNLOADS = 3;
...
@@ -246,9 +255,11 @@
...
@@ -246,9 +255,11 @@
};
};
const deleteModelHandler = async () => {
const deleteModelHandler = async () => {
const res = await deleteModel(localStorage.token, deleteModelTag).catch((error) => {
const res = await deleteModel(localStorage.token, deleteModelTag, selectedOllamaUrlIdx).catch(
(error) => {
toast.error(error);
toast.error(error);
});
}
);
if (res) {
if (res) {
toast.success($i18n.t(`Deleted {{deleteModelTag}}`, { deleteModelTag }));
toast.success($i18n.t(`Deleted {{deleteModelTag}}`, { deleteModelTag }));
...
@@ -259,10 +270,12 @@
...
@@ -259,10 +270,12 @@
};
};
const pullModelHandlerProcessor = async (opts: { modelName: string; callback: Function }) => {
const pullModelHandlerProcessor = async (opts: { modelName: string; callback: Function }) => {
const res = await pullModel(localStorage.token, opts.modelName).catch((error) => {
const res = await pullModel(localStorage.token, opts.modelName, selectedOllamaUrlIdx).catch(
(error) => {
opts.callback({ success: false, error, modelName: opts.modelName });
opts.callback({ success: false, error, modelName: opts.modelName });
return null;
return null;
});
}
);
if (res) {
if (res) {
const reader = res.body
const reader = res.body
...
@@ -368,6 +381,15 @@
...
@@ -368,6 +381,15 @@
};
};
onMount(async () => {
onMount(async () => {
OLLAMA_URLS = await getOllamaUrls(localStorage.token).catch((error) => {
toast.error(error);
return [];
});
if (OLLAMA_URLS.length > 1) {
selectedOllamaUrlIdx = 0;
}
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
liteLLMModelInfo = await getLiteLLMModelInfo(localStorage.token);
liteLLMModelInfo = await getLiteLLMModelInfo(localStorage.token);
});
});
...
@@ -377,20 +399,35 @@
...
@@ -377,20 +399,35 @@
<div class=" space-y-3 pr-1.5 overflow-y-scroll h-[23rem]">
<div class=" space-y-3 pr-1.5 overflow-y-scroll h-[23rem]">
{#if ollamaVersion}
{#if ollamaVersion}
<div class="space-y-2 pr-1.5">
<div class="space-y-2 pr-1.5">
<div>
<div class="text-sm font-medium">{$i18n.t('Manage Ollama Models')}</div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Manage Ollama Models')}</div>
{#if OLLAMA_URLS.length > 1}
<div class="flex-1 pb-1">
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
bind:value={selectedOllamaUrlIdx}
placeholder="Select an Ollama instance"
>
{#each OLLAMA_URLS as url, idx}
<option value={idx} class="bg-gray-100 dark:bg-gray-700">{url}</option>
{/each}
</select>
</div>
{/if}
<div class="space-y-2">
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div>
<div class="flex w-full">
<div class="flex w-full">
<div class="flex-1 mr-2">
<div class="flex-1 mr-2">
<input
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded
-lg
py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter model tag (e.g. mistral:7b)"
placeholder="Enter model tag (e.g. mistral:7b)"
bind:value={modelTag}
bind:value={modelTag}
/>
/>
</div>
</div>
<button
<button
class="px-
3
bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded transition"
class="px-
2.5
bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded
-lg
transition"
on:click={() => {
on:click={() => {
pullModelHandler();
pullModelHandler();
}}
}}
...
@@ -441,11 +478,10 @@
...
@@ -441,11 +478,10 @@
</div>
</div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
{$i18n.t('To access the available model names for downloading,')}
To access the available model names for downloading, <a
<a
class=" text-gray-500 dark:text-gray-300 font-medium underline"
class=" text-gray-500 dark:text-gray-300 font-medium underline"
href="https://ollama.com/library"
href="https://ollama.com/library"
target="_blank">
{$i18n.t('
click here.
')}
</a
target="_blank">click here.</a
>
>
</div>
</div>
...
@@ -470,16 +506,16 @@
...
@@ -470,16 +506,16 @@
</div>
</div>
<div>
<div>
<div class=" mb-2 text-sm font-medium">
{$i18n.t('
Delete a model
')}
</div>
<div class=" mb-2 text-sm font-medium">Delete a model</div>
<div class="flex w-full">
<div class="flex w-full">
<div class="flex-1 mr-2">
<div class="flex-1 mr-2">
<select
<select
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 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={deleteModelTag}
bind:value={deleteModelTag}
placeholder=
{$i18n.t('
Select a model
')}
placeholder=
"
Select a model
"
>
>
{#if !deleteModelTag}
{#if !deleteModelTag}
<option value="" disabled selected>
{$i18n.t('
Select a model
')}
</option>
<option value="" disabled selected>Select a model</option>
{/if}
{/if}
{#each $models.filter((m) => m.size != null) as model}
{#each $models.filter((m) => m.size != null) as model}
<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
...
@@ -489,7 +525,7 @@
...
@@ -489,7 +525,7 @@
</select>
</select>
</div>
</div>
<button
<button
class="px-
3
bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded transition"
class="px-
2.5
bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded
-lg
transition"
on:click={() => {
on:click={() => {
deleteModelHandler();
deleteModelHandler();
}}
}}
...
@@ -510,15 +546,15 @@
...
@@ -510,15 +546,15 @@
</div>
</div>
</div>
</div>
<div>
<div
class="pt-1"
>
<div class="flex justify-between items-center text-xs">
<div class="flex justify-between items-center text-xs">
<div class=" text-sm font-medium">
{$i18n.t('
Experimental
')}
</div>
<div class=" text-sm font-medium">Experimental</div>
<button
<button
class=" text-xs font-medium text-gray-500"
class=" text-xs font-medium text-gray-500"
type="button"
type="button"
on:click={() => {
on:click={() => {
showExperimentalOllama = !showExperimentalOllama;
showExperimentalOllama = !showExperimentalOllama;
}}>{showExperimentalOllama ?
$i18n.t('Show') : $i18n.t('Hide')
}</button
}}>{showExperimentalOllama ?
'Hide' : 'Show'
}</button
>
>
</div>
</div>
</div>
</div>
...
@@ -530,7 +566,7 @@
...
@@ -530,7 +566,7 @@
}}
}}
>
>
<div class=" mb-2 flex w-full justify-between">
<div class=" mb-2 flex w-full justify-between">
<div class=" text-sm font-medium">
{$i18n.t('
Upload a GGUF model
')}
</div>
<div class=" text-sm font-medium">Upload a GGUF model</div>
<button
<button
class="p-1 px-3 text-xs flex rounded transition"
class="p-1 px-3 text-xs flex rounded transition"
...
@@ -544,9 +580,9 @@
...
@@ -544,9 +580,9 @@
type="button"
type="button"
>
>
{#if modelUploadMode === 'file'}
{#if modelUploadMode === 'file'}
<span class="ml-2 self-center">
{$i18n.t('
File Mode
')}
</span>
<span class="ml-2 self-center">File Mode</span>
{:else}
{:else}
<span class="ml-2 self-center">
{$i18n.t('
URL Mode
')}
</span>
<span class="ml-2 self-center">URL Mode</span>
{/if}
{/if}
</button>
</button>
</div>
</div>
...
@@ -557,6 +593,7 @@
...
@@ -557,6 +593,7 @@
<div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}">
<div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}">
<input
<input
id="model-upload-input"
id="model-upload-input"
bind:this={modelUploadInputElement}
type="file"
type="file"
bind:files={modelInputFile}
bind:files={modelInputFile}
on:change={() => {
on:change={() => {
...
@@ -569,10 +606,8 @@
...
@@ -569,10 +606,8 @@
<button
<button
type="button"
type="button"
class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850"
class="w-full rounded-lg text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850"
on:click={() => {
on:click={modelUploadInputElement.click}
document.getElementById('model-upload-input').click();
}}
>
>
{#if modelInputFile && modelInputFile.length > 0}
{#if modelInputFile && modelInputFile.length > 0}
{modelInputFile[0].name}
{modelInputFile[0].name}
...
@@ -584,7 +619,7 @@
...
@@ -584,7 +619,7 @@
{:else}
{:else}
<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
<input
<input
class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !==
class="w-full rounded
-lg
text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !==
''
''
? 'mr-2'
? 'mr-2'
: ''}"
: ''}"
...
@@ -651,7 +686,7 @@
...
@@ -651,7 +686,7 @@
{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
<div>
<div>
<div>
<div>
<div class=" my-2.5 text-sm font-medium">
{$i18n.t('
Modelfile Content
')}
</div>
<div class=" my-2.5 text-sm font-medium">Modelfile Content</div>
<textarea
<textarea
bind:value={modelFileContent}
bind:value={modelFileContent}
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
...
@@ -664,13 +699,13 @@
...
@@ -664,13 +699,13 @@
To access the GGUF models available for downloading, <a
To access the GGUF models available for downloading, <a
class=" text-gray-500 dark:text-gray-300 font-medium underline"
class=" text-gray-500 dark:text-gray-300 font-medium underline"
href="https://huggingface.co/models?search=gguf"
href="https://huggingface.co/models?search=gguf"
target="_blank">
{$i18n.t('
click here.
')}
</a
target="_blank">click here.</a
>
>
</div>
</div>
{#if uploadProgress !== null}
{#if uploadProgress !== null}
<div class="mt-2">
<div class="mt-2">
<div class=" mb-2 text-xs">
{$i18n.t('
Upload Progress
')}
</div>
<div class=" mb-2 text-xs">Upload Progress</div>
<div class="w-full rounded-full dark:bg-gray-800">
<div class="w-full rounded-full dark:bg-gray-800">
<div
<div
...
@@ -688,6 +723,7 @@
...
@@ -688,6 +723,7 @@
</form>
</form>
{/if}
{/if}
</div>
</div>
</div>
<hr class=" dark:border-gray-700 my-2" />
<hr class=" dark:border-gray-700 my-2" />
{/if}
{/if}
...
@@ -704,7 +740,7 @@
...
@@ -704,7 +740,7 @@
type="button"
type="button"
on:click={() => {
on:click={() => {
showLiteLLMParams = !showLiteLLMParams;
showLiteLLMParams = !showLiteLLMParams;
}}>{showLiteLLMParams ?
$i18n.t('Advanced') : $i18n.t('Default')
}</button
}}>{showLiteLLMParams ?
'Hide Additional Params' : 'Show Additional Params'
}</button
>
>
</div>
</div>
</div>
</div>
...
@@ -713,7 +749,7 @@
...
@@ -713,7 +749,7 @@
<div class="flex w-full mb-1.5">
<div class="flex w-full mb-1.5">
<div class="flex-1 mr-2">
<div class="flex-1 mr-2">
<input
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded
-lg
py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter LiteLLM Model (litellm_params.model)"
placeholder="Enter LiteLLM Model (litellm_params.model)"
bind:value={liteLLMModel}
bind:value={liteLLMModel}
autocomplete="off"
autocomplete="off"
...
@@ -721,7 +757,7 @@
...
@@ -721,7 +757,7 @@
</div>
</div>
<button
<button
class="px-
3
bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded transition"
class="px-
2.5
bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded
-lg
transition"
on:click={() => {
on:click={() => {
addLiteLLMModelHandler();
addLiteLLMModelHandler();
}}
}}
...
@@ -745,7 +781,7 @@
...
@@ -745,7 +781,7 @@
<div class="flex w-full">
<div class="flex w-full">
<div class="flex-1">
<div class="flex-1">
<input
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded
-lg
py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter Model Name (model_name)"
placeholder="Enter Model Name (model_name)"
bind:value={liteLLMModelName}
bind:value={liteLLMModelName}
autocomplete="off"
autocomplete="off"
...
@@ -759,7 +795,7 @@
...
@@ -759,7 +795,7 @@
<div class="flex w-full">
<div class="flex w-full">
<div class="flex-1">
<div class="flex-1">
<input
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded
-lg
py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter LiteLLM API Base URL (litellm_params.api_base)"
placeholder="Enter LiteLLM API Base URL (litellm_params.api_base)"
bind:value={liteLLMAPIBase}
bind:value={liteLLMAPIBase}
autocomplete="off"
autocomplete="off"
...
@@ -773,7 +809,7 @@
...
@@ -773,7 +809,7 @@
<div class="flex w-full">
<div class="flex w-full">
<div class="flex-1">
<div class="flex-1">
<input
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded
-lg
py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter LiteLLM API Key (litellm_params.api_key)"
placeholder="Enter LiteLLM API Key (litellm_params.api_key)"
bind:value={liteLLMAPIKey}
bind:value={liteLLMAPIKey}
autocomplete="off"
autocomplete="off"
...
@@ -787,7 +823,7 @@
...
@@ -787,7 +823,7 @@
<div class="flex w-full">
<div class="flex w-full">
<div class="flex-1">
<div class="flex-1">
<input
<input
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
class="w-full rounded
-lg
py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
placeholder="Enter LiteLLM API RPM (litellm_params.rpm)"
placeholder="Enter LiteLLM API RPM (litellm_params.rpm)"
bind:value={liteLLMRPM}
bind:value={liteLLMRPM}
autocomplete="off"
autocomplete="off"
...
@@ -814,7 +850,7 @@
...
@@ -814,7 +850,7 @@
<div class="flex w-full">
<div class="flex w-full">
<div class="flex-1 mr-2">
<div class="flex-1 mr-2">
<select
<select
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 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={deleteLiteLLMModelId}
bind:value={deleteLiteLLMModelId}
placeholder={$i18n.t('Select a model')}
placeholder={$i18n.t('Select a model')}
>
>
...
@@ -829,7 +865,7 @@
...
@@ -829,7 +865,7 @@
</select>
</select>
</div>
</div>
<button
<button
class="px-
3
bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded transition"
class="px-
2.5
bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded
-lg
transition"
on:click={() => {
on:click={() => {
deleteLiteLLMModelHandler();
deleteLiteLLMModelHandler();
}}
}}
...
...
src/lib/components/common/Tooltip.svelte
View file @
25e0f0de
...
@@ -29,6 +29,6 @@
...
@@ -29,6 +29,6 @@
});
});
</script>
</script>
<div bind:this={tooltipElement}>
<div bind:this={tooltipElement}
aria-label={content}
>
<slot />
<slot />
</div>
</div>
src/lib/components/documents/AddDocModal.svelte
View file @
25e0f0de
...
@@ -17,7 +17,7 @@
...
@@ -17,7 +17,7 @@
export let show = false;
export let show = false;
export let selectedDoc;
export let selectedDoc;
let uploadDocInputElement: HTMLInputElement;
let inputFiles;
let inputFiles;
let tags = [];
let tags = [];
...
@@ -71,7 +71,7 @@
...
@@ -71,7 +71,7 @@
}
}
inputFiles = null;
inputFiles = null;
document.getElementById('upload-doc-input')
.value = '';
uploadDocInputElement
.value = '';
} else {
} else {
toast.error($i18n.t(`File not found.`));
toast.error($i18n.t(`File not found.`));
}
}
...
@@ -128,14 +128,19 @@
...
@@ -128,14 +128,19 @@
}}
}}
>
>
<div class="mb-3 w-full">
<div class="mb-3 w-full">
<input id="upload-doc-input" hidden bind:files={inputFiles} type="file" multiple />
<input
id="upload-doc-input"
bind:this={uploadDocInputElement}
hidden
bind:files={inputFiles}
type="file"
multiple
/>
<button
<button
class="w-full text-sm font-medium py-3 bg-gray-850 hover:bg-gray-800 text-center rounded-xl"
class="w-full text-sm font-medium py-3 bg-gray-850 hover:bg-gray-800 text-center rounded-xl"
type="button"
type="button"
on:click={() => {
on:click={uploadDocInputElement.click}
document.getElementById('upload-doc-input')?.click();
}}
>
>
{#if inputFiles}
{#if inputFiles}
{inputFiles.length > 0 ? `${inputFiles.length}` : ''} document(s) selected.
{inputFiles.length > 0 ? `${inputFiles.length}` : ''} document(s) selected.
...
...
src/lib/components/playground/ChatCompletion.svelte
View file @
25e0f0de
...
@@ -4,12 +4,11 @@
...
@@ -4,12 +4,11 @@
const i18n = getContext('i18n');
const i18n = getContext('i18n');
export let messages = [];
export let messages = [];
let textAreaElement: HTMLTextAreaElement;
onMount(() => {
onMount(() => {
messages.forEach((message, idx) => {
messages.forEach((message, idx) => {
let textareaElement = document.getElementById(`${message.role}-${idx}-textarea`);
textAreaElement.style.height = '';
textareaElement.style.height = '';
textAreaElement.style.height = textAreaElement.scrollHeight + 'px';
textareaElement.style.height = textareaElement.scrollHeight + 'px';
});
});
});
});
</script>
</script>
...
@@ -29,18 +28,19 @@
...
@@ -29,18 +28,19 @@
<div class="flex-1">
<div class="flex-1">
<textarea
<textarea
id="{message.role}-{idx}-textarea"
id="{message.role}-{idx}-textarea"
bind:this={textAreaElement}
class="w-full bg-transparent outline-none rounded-lg p-2 text-sm resize-none overflow-hidden"
class="w-full bg-transparent outline-none rounded-lg p-2 text-sm resize-none overflow-hidden"
placeholder={$i18n.t(
placeholder={$i18n.t(
`Enter ${message.role === 'user' ? 'a user' : 'an assistant'} message here`
`Enter ${message.role === 'user' ? 'a user' : 'an assistant'} message here`
)}
)}
rows="1"
rows="1"
on:input={(e) => {
on:input={(e) => {
e.targe
t.style.height = '';
textAreaElemen
t.style.height = '';
e.targe
t.style.height =
e.targe
t.scrollHeight + 'px';
textAreaElemen
t.style.height =
textAreaElemen
t.scrollHeight + 'px';
}}
}}
on:focus={(e) => {
on:focus={(e) => {
e.targe
t.style.height = '';
textAreaElemen
t.style.height = '';
e.targe
t.style.height =
e.targe
t.scrollHeight + 'px';
textAreaElemen
t.style.height =
textAreaElemen
t.scrollHeight + 'px';
// e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
// e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
}}
}}
...
...
src/lib/constants.ts
View file @
25e0f0de
...
@@ -7,7 +7,7 @@ export const WEBUI_BASE_URL = dev ? `http://${location.hostname}:8080` : ``;
...
@@ -7,7 +7,7 @@ export const WEBUI_BASE_URL = dev ? `http://${location.hostname}:8080` : ``;
export
const
WEBUI_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/api/v1`
;
export
const
WEBUI_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/api/v1`
;
export
const
LITELLM_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/litellm/api`
;
export
const
LITELLM_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/litellm/api`
;
export
const
OLLAMA_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/ollama
/api
`
;
export
const
OLLAMA_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/ollama`
;
export
const
OPENAI_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/openai/api`
;
export
const
OPENAI_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/openai/api`
;
export
const
AUDIO_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/audio/api/v1`
;
export
const
AUDIO_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/audio/api/v1`
;
export
const
IMAGES_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/images/api/v1`
;
export
const
IMAGES_API_BASE_URL
=
`
${
WEBUI_BASE_URL
}
/images/api/v1`
;
...
...
src/routes/(app)/+layout.svelte
View file @
25e0f0de
...
@@ -34,12 +34,13 @@
...
@@ -34,12 +34,13 @@
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
import ShortcutsModal from '$lib/components/chat/ShortcutsModal.svelte';
import ChangelogModal from '$lib/components/ChangelogModal.svelte';
import ChangelogModal from '$lib/components/ChangelogModal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
const i18n = getContext('i18n');
let ollamaVersion = '';
let ollamaVersion = '';
let loaded = false;
let loaded = false;
let showShortcutsButtonElement: HTMLButtonElement;
let DB = null;
let DB = null;
let localDBChats = [];
let localDBChats = [];
...
@@ -186,7 +187,7 @@
...
@@ -186,7 +187,7 @@
if (isCtrlPressed && event.key === '/') {
if (isCtrlPressed && event.key === '/') {
event.preventDefault();
event.preventDefault();
console.log('showShortcuts');
console.log('showShortcuts');
document.getElementById('
show
-s
hortcuts
-b
utton
')?
.click();
show
S
hortcuts
B
utton
Element
.click();
}
}
});
});
...
@@ -203,8 +204,10 @@
...
@@ -203,8 +204,10 @@
{#if loaded}
{#if loaded}
<div class=" hidden lg:flex fixed bottom-0 right-0 px-3 py-3 z-10">
<div class=" hidden lg:flex fixed bottom-0 right-0 px-3 py-3 z-10">
<Tooltip content="help" placement="left">
<button
<button
id="show-shortcuts-button"
id="show-shortcuts-button"
bind:this={showShortcutsButtonElement}
class="text-gray-600 dark:text-gray-300 bg-gray-300/20 w-6 h-6 flex items-center justify-center text-xs rounded-full"
class="text-gray-600 dark:text-gray-300 bg-gray-300/20 w-6 h-6 flex items-center justify-center text-xs rounded-full"
on:click={() => {
on:click={() => {
showShortcuts = !showShortcuts;
showShortcuts = !showShortcuts;
...
@@ -212,6 +215,7 @@
...
@@ -212,6 +215,7 @@
>
>
?
?
</button>
</button>
</Tooltip>
</div>
</div>
<ShortcutsModal bind:show={showShortcuts} />
<ShortcutsModal bind:show={showShortcuts} />
...
...
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment