forked from open-webui/open-webui
commit
880f58e81a
28 changed files with 1505 additions and 946 deletions
|
@ -61,7 +61,7 @@ Also check our sibling project, [OllamaHub](https://ollamahub.com/), where you c
|
|||
|
||||
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
|
||||
|
||||
- 🔒 **Backend Reverse Proxy Support**: Strengthen security by enabling direct communication between Ollama Web UI backend and Ollama, eliminating the need to expose Ollama over LAN.
|
||||
- 🔒 **Backend Reverse Proxy Support**: Bolster security through direct communication between Ollama Web UI backend and Ollama. This key feature eliminates the need to expose Ollama over LAN. Requests made to the '/ollama/api' route from the web UI are seamlessly redirected to Ollama from the backend, enhancing overall system security.
|
||||
|
||||
- 🌟 **Continuous Updates**: We are committed to improving Ollama Web UI with regular updates and new features.
|
||||
|
||||
|
@ -71,11 +71,14 @@ Don't forget to explore our sibling project, [OllamaHub](https://ollamahub.com/)
|
|||
|
||||
## How to Install 🚀
|
||||
|
||||
🌟 **Important Note on User Roles:**
|
||||
🌟 **Important Note on User Roles and Privacy:**
|
||||
|
||||
- **Admin Creation:** The very first account to sign up on the Ollama Web UI will be granted **Administrator privileges**. This account will have comprehensive control over the platform, including user management and system settings.
|
||||
|
||||
- **User Registrations:** All subsequent users signing up will initially have their accounts set to **Pending** status by default. These accounts will require approval from the Administrator to gain access to the platform functionalities.
|
||||
|
||||
- **Privacy and Data Security:** We prioritize your privacy and data security above all. Please be reassured that all data entered into the Ollama Web UI is stored locally on your device. Our system is designed to be privacy-first, ensuring that no external requests are made, and your data does not leave your local environment. We are committed to maintaining the highest standards of data privacy and security, ensuring that your information remains confidential and under your control.
|
||||
|
||||
### Installing Both Ollama and Ollama Web UI Using Docker Compose
|
||||
|
||||
If you don't have Ollama installed yet, you can use the provided Docker Compose file for a hassle-free installation. Simply run the following command:
|
||||
|
|
|
@ -1,30 +1,32 @@
|
|||
# Ollama Web UI Troubleshooting Guide
|
||||
|
||||
## Understanding the Ollama WebUI Architecture
|
||||
|
||||
The Ollama WebUI system is designed to streamline interactions between the client (your browser) and the Ollama API. At the heart of this design is a backend reverse proxy, enhancing security and resolving CORS issues.
|
||||
|
||||
- **How it Works**: When you make a request (like `/ollama/api/tags`) from the Ollama WebUI, it doesn’t go directly to the Ollama API. Instead, it first reaches the Ollama WebUI backend. The backend then forwards this request to the Ollama API via the route you define in the `OLLAMA_API_BASE_URL` environment variable. For instance, a request to `/ollama/api/tags` in the WebUI is equivalent to `OLLAMA_API_BASE_URL/tags` in the backend.
|
||||
|
||||
- **Security Benefits**: This design prevents direct exposure of the Ollama API to the frontend, safeguarding against potential CORS (Cross-Origin Resource Sharing) issues and unauthorized access. Requiring authentication to access the Ollama API further enhances this security layer.
|
||||
|
||||
## Ollama WebUI: Server Connection Error
|
||||
|
||||
If you're running ollama-webui and have chosen to install webui and ollama separately, you might encounter connection issues. This is often due to the docker container being unable to reach the Ollama server at 127.0.0.1:11434(host.docker.internal:11434). To resolve this, you can use the `--network=host` flag in the docker command. When done so port would be changed from 3000 to 8080, and the link would be: http://localhost:8080.
|
||||
If you're experiencing connection issues, it’s often due to the WebUI docker container not being able to reach the Ollama server at 127.0.0.1:11434 (host.docker.internal:11434) inside the container . Use the `--network=host` flag in your docker command to resolve this. Note that the port changes from 3000 to 8080, resulting in the link: `http://localhost:8080`.
|
||||
|
||||
Here's an example of the command you should run:
|
||||
**Example Docker Command**:
|
||||
|
||||
```bash
|
||||
docker run -d --network=host -v ollama-webui:/app/backend/data -e OLLAMA_API_BASE_URL=http://127.0.0.1:11434/api --name ollama-webui --restart always ghcr.io/ollama-webui/ollama-webui:main
|
||||
```
|
||||
|
||||
## Connection Errors
|
||||
### General Connection Errors
|
||||
|
||||
Make sure you have the **latest version of Ollama** installed before proceeding with the installation. You can find the latest version of Ollama at [https://ollama.ai/](https://ollama.ai/).
|
||||
**Ensure Ollama Version is Up-to-Date**: Always start by checking that you have the latest version of Ollama. Visit [Ollama's official site](https://ollama.ai/) for the latest updates.
|
||||
|
||||
If you encounter difficulties connecting to the Ollama server, please follow these steps to diagnose and resolve the issue:
|
||||
**Troubleshooting Steps**:
|
||||
|
||||
**1. Check Ollama URL Format**
|
||||
1. **Verify Ollama URL Format**:
|
||||
- When running the Web UI container, ensure the `OLLAMA_API_BASE_URL` is correctly set, including the `/api` suffix. (e.g., `http://192.168.1.1:11434/api` for different host setups).
|
||||
- In the Ollama WebUI, navigate to "Settings" > "General".
|
||||
- Confirm that the Ollama Server URL is correctly set to `/ollama/api`, including the `/api` suffix.
|
||||
|
||||
Ensure that the Ollama URL is correctly formatted in the application settings. Follow these steps:
|
||||
|
||||
- If your Ollama runs in a different host than Web UI make sure Ollama host address is provided when running Web UI container via `OLLAMA_API_BASE_URL` environment variable. [(e.g. OLLAMA_API_BASE_URL=http://192.168.1.1:11434/api)](https://github.com/ollama-webui/ollama-webui#accessing-external-ollama-on-a-different-server)
|
||||
- Go to "Settings" within the Ollama WebUI.
|
||||
- Navigate to the "General" section.
|
||||
- Verify that the Ollama Server URL is set to: `/ollama/api`.
|
||||
|
||||
It is crucial to include the `/api` at the end of the URL to ensure that the Ollama Web UI can communicate with the server.
|
||||
|
||||
By following these troubleshooting steps, you should be able to identify and resolve connection issues with your Ollama server configuration. If you require further assistance or have additional questions, please don't hesitate to reach out or refer to our documentation for comprehensive guidance.
|
||||
By following these enhanced troubleshooting steps, connection issues should be effectively resolved. For further assistance or queries, feel free to reach out to us on our community Discord.
|
||||
|
|
|
@ -64,6 +64,11 @@ class SigninForm(BaseModel):
|
|||
password: str
|
||||
|
||||
|
||||
class UpdatePasswordForm(BaseModel):
|
||||
password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class SignupForm(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
|
@ -109,5 +114,30 @@ class AuthsTable:
|
|||
except:
|
||||
return None
|
||||
|
||||
def update_user_password_by_id(self, id: str, new_password: str) -> bool:
|
||||
try:
|
||||
query = Auth.update(password=new_password).where(Auth.id == id)
|
||||
result = query.execute()
|
||||
|
||||
return True if result == 1 else False
|
||||
except:
|
||||
return False
|
||||
|
||||
def delete_auth_by_id(self, id: str) -> bool:
|
||||
try:
|
||||
# Delete User
|
||||
result = Users.delete_user_by_id(id)
|
||||
|
||||
if result:
|
||||
# Delete Auth
|
||||
query = Auth.delete().where(Auth.id == id)
|
||||
query.execute() # Remove the rows, return number of rows removed.
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
Auths = AuthsTable(DB)
|
||||
|
|
|
@ -153,5 +153,14 @@ class ChatTable:
|
|||
except:
|
||||
return False
|
||||
|
||||
def delete_chats_by_user_id(self, user_id: str) -> bool:
|
||||
try:
|
||||
query = Chat.delete().where(Chat.user_id == user_id)
|
||||
query.execute() # Remove the rows, return number of rows removed.
|
||||
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
Chats = ChatTable(DB)
|
||||
|
|
|
@ -8,6 +8,8 @@ from utils.utils import decode_token
|
|||
from utils.misc import get_gravatar_url
|
||||
|
||||
from apps.web.internal.db import DB
|
||||
from apps.web.models.chats import Chats
|
||||
|
||||
|
||||
####################
|
||||
# User DB Schema
|
||||
|
@ -110,5 +112,21 @@ class UsersTable:
|
|||
except:
|
||||
return None
|
||||
|
||||
def delete_user_by_id(self, id: str) -> bool:
|
||||
try:
|
||||
# Delete User Chats
|
||||
result = Chats.delete_chats_by_user_id(id)
|
||||
|
||||
if result:
|
||||
# Delete User
|
||||
query = User.delete().where(User.id == id)
|
||||
query.execute() # Remove the rows, return number of rows removed.
|
||||
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
Users = UsersTable(DB)
|
||||
|
|
|
@ -11,6 +11,7 @@ import uuid
|
|||
from apps.web.models.auths import (
|
||||
SigninForm,
|
||||
SignupForm,
|
||||
UpdatePasswordForm,
|
||||
UserResponse,
|
||||
SigninResponse,
|
||||
Auths,
|
||||
|
@ -53,6 +54,28 @@ async def get_session_user(cred=Depends(bearer_scheme)):
|
|||
)
|
||||
|
||||
|
||||
############################
|
||||
# Update Password
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/update/password", response_model=bool)
|
||||
async def update_password(form_data: UpdatePasswordForm, cred=Depends(bearer_scheme)):
|
||||
token = cred.credentials
|
||||
session_user = Users.get_user_by_token(token)
|
||||
|
||||
if session_user:
|
||||
user = Auths.authenticate_user(session_user.email, form_data.password)
|
||||
|
||||
if user:
|
||||
hashed = get_password_hash(form_data.new_password)
|
||||
return Auths.update_user_password_by_id(user.id, hashed)
|
||||
else:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_PASSWORD)
|
||||
else:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
|
||||
############################
|
||||
# SignIn
|
||||
############################
|
||||
|
|
|
@ -159,3 +159,23 @@ async def delete_chat_by_id(id: str, cred=Depends(bearer_scheme)):
|
|||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteAllChats
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/", response_model=bool)
|
||||
async def delete_all_user_chats(cred=Depends(bearer_scheme)):
|
||||
token = cred.credentials
|
||||
user = Users.get_user_by_token(token)
|
||||
|
||||
if user:
|
||||
result = Chats.delete_chats_by_user_id(user.id)
|
||||
return result
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
|
|
@ -9,6 +9,8 @@ import time
|
|||
import uuid
|
||||
|
||||
from apps.web.models.users import UserModel, UserRoleUpdateForm, Users
|
||||
from apps.web.models.auths import Auths
|
||||
|
||||
|
||||
from utils.utils import (
|
||||
get_password_hash,
|
||||
|
@ -73,3 +75,42 @@ async def update_user_role(form_data: UserRoleUpdateForm, cred=Depends(bearer_sc
|
|||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeleteUserById
|
||||
############################
|
||||
|
||||
|
||||
@router.delete("/{user_id}", response_model=bool)
|
||||
async def delete_user_by_id(user_id: str, cred=Depends(bearer_scheme)):
|
||||
token = cred.credentials
|
||||
user = Users.get_user_by_token(token)
|
||||
|
||||
if user:
|
||||
if user.role == "admin":
|
||||
if user.id != user_id:
|
||||
result = Auths.delete_auth_by_id(user_id)
|
||||
|
||||
if result:
|
||||
return True
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=ERROR_MESSAGES.DELETE_USER_ERROR,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
|
|
@ -12,6 +12,7 @@ class ERROR_MESSAGES(str, Enum):
|
|||
DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}"
|
||||
ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now."
|
||||
CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance."
|
||||
DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot."
|
||||
EMAIL_TAKEN = "Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew."
|
||||
USERNAME_TAKEN = (
|
||||
"Uh-oh! This username is already registered. Please choose another username."
|
||||
|
@ -20,6 +21,9 @@ class ERROR_MESSAGES(str, Enum):
|
|||
"Your session has expired or the token is invalid. Please sign in again."
|
||||
)
|
||||
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
|
||||
INVALID_PASSWORD = (
|
||||
"The password provided is incorrect. Please check for typos and try again."
|
||||
)
|
||||
UNAUTHORIZED = "401 Unauthorized"
|
||||
ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance."
|
||||
ACTION_PROHIBITED = (
|
||||
|
@ -27,4 +31,5 @@ class ERROR_MESSAGES(str, Enum):
|
|||
)
|
||||
NOT_FOUND = "We could not find what you're looking for :/"
|
||||
USER_NOT_FOUND = "We could not find what you're looking for :/"
|
||||
|
||||
MALICIOUS = "Unusual activities detected, please try again in a few minutes."
|
||||
|
|
|
@ -88,3 +88,34 @@ export const userSignUp = async (name: string, email: string, password: string)
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateUserPassword = async (token: string, password: string, newPassword: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/update/password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
password: password,
|
||||
new_password: newPassword
|
||||
})
|
||||
})
|
||||
.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;
|
||||
};
|
||||
|
|
|
@ -191,3 +191,35 @@ export const deleteChatById = async (token: string, id: string) => {
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteAllChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/`, {
|
||||
method: 'DELETE',
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -45,8 +45,9 @@ export const getUsers = async (token: string) => {
|
|||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
|
@ -56,3 +57,30 @@ export const getUsers = async (token: string) => {
|
|||
|
||||
return res ? res : [];
|
||||
};
|
||||
|
||||
export const deleteUserById = async (token: string, userId: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import tippy from 'tippy.js';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github-dark.min.css';
|
||||
import auto_render from 'katex/dist/contrib/auto-render.mjs';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import { chats, config, db, modelfiles, settings, user } from '$lib/stores';
|
||||
import { tick } from 'svelte';
|
||||
|
@ -14,6 +7,10 @@
|
|||
import toast from 'svelte-french-toast';
|
||||
import { getChatList, updateChatById } from '$lib/apis/chats';
|
||||
|
||||
import UserMessage from './Messages/UserMessage.svelte';
|
||||
import ResponseMessage from './Messages/ResponseMessage.svelte';
|
||||
import Placeholder from './Messages/Placeholder.svelte';
|
||||
|
||||
export let chatId = '';
|
||||
export let sendPrompt: Function;
|
||||
export let regenerateResponse: Function;
|
||||
|
@ -24,56 +21,7 @@
|
|||
export let history = {};
|
||||
export let messages = [];
|
||||
|
||||
export let selectedModelfile = null;
|
||||
|
||||
$: if (messages && messages.length > 0 && (messages.at(-1).done ?? false)) {
|
||||
(async () => {
|
||||
await tick();
|
||||
|
||||
[...document.querySelectorAll('*')].forEach((node) => {
|
||||
if (node._tippy) {
|
||||
node._tippy.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('rendering message');
|
||||
|
||||
renderLatex();
|
||||
hljs.highlightAll();
|
||||
createCopyCodeBlockButton();
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info) {
|
||||
console.log(message);
|
||||
|
||||
tippy(`#info-${message.id}`, {
|
||||
content: `<span class="text-xs" id="tooltip-${message.id}">token/s: ${
|
||||
`${
|
||||
Math.round(
|
||||
((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100
|
||||
) / 100
|
||||
} tokens` ?? 'N/A'
|
||||
}<br/>
|
||||
total_duration: ${
|
||||
Math.round(((message.info.total_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
|
||||
}ms<br/>
|
||||
load_duration: ${
|
||||
Math.round(((message.info.load_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
|
||||
}ms<br/>
|
||||
prompt_eval_count: ${message.info.prompt_eval_count ?? 'N/A'}<br/>
|
||||
prompt_eval_duration: ${
|
||||
Math.round(((message.info.prompt_eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
|
||||
}ms<br/>
|
||||
eval_count: ${message.info.eval_count ?? 'N/A'}<br/>
|
||||
eval_duration: ${
|
||||
Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
|
||||
}ms</span>`,
|
||||
allowHTML: true
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
export let selectedModelfiles = [];
|
||||
|
||||
$: if (autoScroll && bottomPadding) {
|
||||
(async () => {
|
||||
|
@ -82,95 +30,6 @@
|
|||
})();
|
||||
}
|
||||
|
||||
const speakMessage = (message) => {
|
||||
const speak = new SpeechSynthesisUtterance(message);
|
||||
speechSynthesis.speak(speak);
|
||||
};
|
||||
|
||||
const createCopyCodeBlockButton = () => {
|
||||
// use a class selector if available
|
||||
let blocks = document.querySelectorAll('pre');
|
||||
|
||||
blocks.forEach((block) => {
|
||||
// only add button if browser supports Clipboard API
|
||||
|
||||
if (block.childNodes.length < 2 && block.id !== 'user-message') {
|
||||
let code = block.querySelector('code');
|
||||
code.style.borderTopRightRadius = 0;
|
||||
code.style.borderTopLeftRadius = 0;
|
||||
|
||||
let topBarDiv = document.createElement('div');
|
||||
topBarDiv.style.backgroundColor = '#202123';
|
||||
topBarDiv.style.overflowX = 'auto';
|
||||
topBarDiv.style.display = 'flex';
|
||||
topBarDiv.style.justifyContent = 'space-between';
|
||||
topBarDiv.style.padding = '0 1rem';
|
||||
topBarDiv.style.paddingTop = '4px';
|
||||
topBarDiv.style.borderTopRightRadius = '8px';
|
||||
topBarDiv.style.borderTopLeftRadius = '8px';
|
||||
|
||||
let langDiv = document.createElement('div');
|
||||
|
||||
let codeClassNames = code?.className.split(' ');
|
||||
langDiv.textContent =
|
||||
codeClassNames[0] === 'hljs' ? codeClassNames[1].slice(9) : codeClassNames[0].slice(9);
|
||||
langDiv.style.color = 'white';
|
||||
langDiv.style.margin = '4px';
|
||||
langDiv.style.fontSize = '0.75rem';
|
||||
|
||||
let button = document.createElement('button');
|
||||
button.className = 'copy-code-button';
|
||||
button.textContent = 'Copy Code';
|
||||
button.style.background = 'none';
|
||||
button.style.fontSize = '0.75rem';
|
||||
button.style.border = 'none';
|
||||
button.style.margin = '4px';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.color = '#ddd';
|
||||
button.addEventListener('click', () => copyCode(block, button));
|
||||
|
||||
topBarDiv.appendChild(langDiv);
|
||||
topBarDiv.appendChild(button);
|
||||
|
||||
block.prepend(topBarDiv);
|
||||
}
|
||||
});
|
||||
|
||||
async function copyCode(block, button) {
|
||||
let code = block.querySelector('code');
|
||||
let text = code.innerText;
|
||||
|
||||
await copyToClipboard(text);
|
||||
|
||||
// visual feedback that task is completed
|
||||
button.innerText = 'Copied!';
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerText = 'Copy Code';
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const renderLatex = () => {
|
||||
let chatMessageElements = document.getElementsByClassName('chat-assistant');
|
||||
// let lastChatMessageElement = chatMessageElements[chatMessageElements.length - 1];
|
||||
|
||||
for (const element of chatMessageElements) {
|
||||
auto_render(element, {
|
||||
// customised options
|
||||
// • auto-render specific keys, e.g.:
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
// { left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true }
|
||||
],
|
||||
// • rendering keys, e.g.:
|
||||
throwOnError: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
if (!navigator.clipboard) {
|
||||
var textArea = document.createElement('textarea');
|
||||
|
@ -207,24 +66,8 @@
|
|||
);
|
||||
};
|
||||
|
||||
const editMessageHandler = async (messageId) => {
|
||||
// let editMessage = history.messages[messageId];
|
||||
history.messages[messageId].edit = true;
|
||||
history.messages[messageId].originalContent = history.messages[messageId].content;
|
||||
history.messages[messageId].editedContent = history.messages[messageId].content;
|
||||
|
||||
await tick();
|
||||
|
||||
const editElement = document.getElementById(`message-edit-${messageId}`);
|
||||
|
||||
editElement.style.height = '';
|
||||
editElement.style.height = `${editElement.scrollHeight}px`;
|
||||
};
|
||||
|
||||
const confirmEditMessage = async (messageId) => {
|
||||
history.messages[messageId].edit = false;
|
||||
|
||||
let userPrompt = history.messages[messageId].editedContent;
|
||||
const confirmEditMessage = async (messageId, content) => {
|
||||
let userPrompt = content;
|
||||
let userMessageId = uuidv4();
|
||||
|
||||
let userMessage = {
|
||||
|
@ -252,25 +95,23 @@
|
|||
await sendPrompt(userPrompt, userMessageId, chatId);
|
||||
};
|
||||
|
||||
const confirmEditResponseMessage = async (messageId) => {
|
||||
history.messages[messageId].edit = false;
|
||||
history.messages[messageId].content = history.messages[messageId].editedContent;
|
||||
};
|
||||
const confirmEditResponseMessage = async (messageId, content) => {
|
||||
history.messages[messageId].originalContent = history.messages[messageId].content;
|
||||
history.messages[messageId].content = content;
|
||||
|
||||
const cancelEditMessage = (messageId) => {
|
||||
history.messages[messageId].edit = false;
|
||||
history.messages[messageId].editedContent = undefined;
|
||||
};
|
||||
await tick();
|
||||
|
||||
const rateMessage = async (messageIdx, rating) => {
|
||||
// TODO: Move this function to parent
|
||||
messages = messages.map((message, idx) => {
|
||||
if (messageIdx === idx) {
|
||||
message.rating = rating;
|
||||
}
|
||||
return message;
|
||||
await updateChatById(localStorage.token, chatId, {
|
||||
messages: messages,
|
||||
history: history
|
||||
});
|
||||
|
||||
await chats.set(await getChatList(localStorage.token));
|
||||
};
|
||||
|
||||
const rateMessage = async (messageId, rating) => {
|
||||
history.messages[messageId].rating = rating;
|
||||
await tick();
|
||||
await updateChatById(localStorage.token, chatId, {
|
||||
messages: messages,
|
||||
history: history
|
||||
|
@ -372,619 +213,39 @@
|
|||
</script>
|
||||
|
||||
{#if messages.length == 0}
|
||||
<div class="m-auto text-center max-w-md pb-56 px-2">
|
||||
<div class="flex justify-center mt-8">
|
||||
{#if selectedModelfile && selectedModelfile.imageUrl}
|
||||
<img
|
||||
src={selectedModelfile?.imageUrl}
|
||||
alt="modelfile"
|
||||
class=" w-20 mb-2 rounded-full"
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src="/ollama.png"
|
||||
class=" w-16 invert-[10%] dark:invert-[100%] rounded-full"
|
||||
alt="ollama"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class=" mt-2 text-2xl text-gray-800 dark:text-gray-100 font-semibold">
|
||||
{#if selectedModelfile}
|
||||
<span class=" capitalize">
|
||||
{selectedModelfile.title}
|
||||
</span>
|
||||
<div class="mt-0.5 text-base font-normal text-gray-600 dark:text-gray-400">
|
||||
{selectedModelfile.desc}
|
||||
</div>
|
||||
{#if selectedModelfile.user}
|
||||
<div class="mt-0.5 text-sm font-normal text-gray-500 dark:text-gray-500">
|
||||
By <a href="https://ollamahub.com/m/{selectedModelfile.user.username}"
|
||||
>{selectedModelfile.user.name
|
||||
? selectedModelfile.user.name
|
||||
: `@${selectedModelfile.user.username}`}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
How can I help you today?
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Placeholder models={selectedModels} modelfiles={selectedModelfiles} />
|
||||
{:else}
|
||||
{#each messages as message, messageIdx}
|
||||
<div class=" w-full">
|
||||
<div class="flex justify-between px-5 mb-3 max-w-3xl mx-auto rounded-lg group">
|
||||
<div class=" flex w-full">
|
||||
<div class=" mr-4">
|
||||
{#if message.role === 'user'}
|
||||
{#if $config === null || !($config?.auth ?? true)}
|
||||
<img
|
||||
src="{$settings.gravatarUrl ? $settings.gravatarUrl : '/user'}.png"
|
||||
class=" max-w-[28px] object-cover rounded-full"
|
||||
alt="User profile"
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={$user ? $user.profile_image_url : '/user.png'}
|
||||
class=" max-w-[28px] object-cover rounded-full"
|
||||
alt="User profile"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
{:else if selectedModelfile}
|
||||
<img
|
||||
src={selectedModelfile?.imageUrl ?? '/favicon.png'}
|
||||
class=" max-w-[28px] object-cover rounded-full"
|
||||
alt="Ollama profile"
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src="/favicon.png"
|
||||
class=" max-w-[28px] object-cover rounded-full"
|
||||
alt="Ollama profile"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-hidden">
|
||||
<div class=" self-center font-bold mb-0.5">
|
||||
{#if message.role === 'user'}
|
||||
You
|
||||
{:else if selectedModelfile}
|
||||
<span class="capitalize">
|
||||
{selectedModelfile.title}
|
||||
</span>
|
||||
{:else}
|
||||
Ollama <span class=" text-gray-500 text-sm font-medium"
|
||||
>{message.model ? ` ${message.model}` : ''}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if message.role !== 'user' && message.content === ''}
|
||||
<div class="w-full mt-3">
|
||||
<div class="animate-pulse flex w-full">
|
||||
<div class="space-y-2 w-full">
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" />
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4" />
|
||||
</div>
|
||||
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:my-0 prose-p:-mb-4 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-6 prose-li:-mb-4 whitespace-pre-line"
|
||||
>
|
||||
{#if message.role == 'user'}
|
||||
{#if message.files}
|
||||
<div class="my-3 w-full flex overflow-x-auto space-x-2">
|
||||
{#each message.files as file}
|
||||
<div>
|
||||
{#if file.type === 'image'}
|
||||
<img
|
||||
src={file.url}
|
||||
alt="input"
|
||||
class=" max-h-96 rounded-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if message?.edit === true}
|
||||
<div class=" w-full">
|
||||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
class=" bg-transparent outline-none w-full resize-none"
|
||||
bind:value={history.messages[message.id].editedContent}
|
||||
on:input={(e) => {
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class=" mt-2 mb-1 flex justify-center space-x-2 text-sm font-medium">
|
||||
<button
|
||||
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded-lg"
|
||||
on:click={() => {
|
||||
confirmEditMessage(message.id);
|
||||
}}
|
||||
>
|
||||
Save & Submit
|
||||
</button>
|
||||
|
||||
<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(message.id);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full">
|
||||
<pre id="user-message">{message.content}</pre>
|
||||
|
||||
<div class=" flex justify-start space-x-1">
|
||||
{#if message.parentId !== null && message.parentId in history.messages && (history.messages[message.parentId]?.childrenIds.length ?? 0) > 1}
|
||||
<div class="flex self-center">
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showPreviousMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-xs font-bold self-center">
|
||||
{history.messages[message.parentId].childrenIds.indexOf(message.id) +
|
||||
1} / {history.messages[message.parentId].childrenIds.length}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showNextMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else if message.parentId === null && Object.values(history.messages).filter((message) => message.parentId === null).length > 1}
|
||||
<div class="flex self-center">
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showPreviousMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-xs font-bold self-center">
|
||||
{Object.values(history.messages)
|
||||
<UserMessage
|
||||
user={$user}
|
||||
{message}
|
||||
siblings={message.parentId !== null
|
||||
? history.messages[message.parentId]?.childrenIds ?? []
|
||||
: Object.values(history.messages)
|
||||
.filter((message) => message.parentId === null)
|
||||
.map((message) => message.id)
|
||||
.indexOf(message.id) + 1} / {Object.values(history.messages).filter(
|
||||
(message) => message.parentId === null
|
||||
).length}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showNextMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
.map((message) => message.id) ?? []}
|
||||
{confirmEditMessage}
|
||||
{showPreviousMessage}
|
||||
{showNextMessage}
|
||||
{copyToClipboard}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="invisible group-hover:visible p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
editMessageHandler(message.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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="invisible group-hover:visible p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
copyToClipboard(message.content);
|
||||
}}
|
||||
>
|
||||
<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="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if message.role === 'assistant'}
|
||||
<div>
|
||||
{#if message?.edit === true}
|
||||
<div class=" w-full">
|
||||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
class=" bg-transparent outline-none w-full resize-none"
|
||||
bind:value={history.messages[message.id].editedContent}
|
||||
on:input={(e) => {
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class=" mt-2 mb-1 flex justify-center space-x-2 text-sm font-medium">
|
||||
<button
|
||||
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded-lg"
|
||||
on:click={() => {
|
||||
confirmEditResponseMessage(message.id);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<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(message.id);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full">
|
||||
{#if message?.error === true}
|
||||
<div
|
||||
class="flex mt-2 mb-4 space-x-2 border px-4 py-3 border-red-800 bg-red-800/30 font-medium rounded-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 self-center"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
<ResponseMessage
|
||||
{message}
|
||||
modelfiles={selectedModelfiles}
|
||||
siblings={history.messages[message.parentId]?.childrenIds ?? []}
|
||||
isLastMessage={messageIdx + 1 === messages.length}
|
||||
{confirmEditResponseMessage}
|
||||
{showPreviousMessage}
|
||||
{showNextMessage}
|
||||
{rateMessage}
|
||||
{copyToClipboard}
|
||||
{regenerateResponse}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class=" self-center">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@html marked(message.content.replace('\\\\', '\\\\\\'))}
|
||||
{/if}
|
||||
|
||||
{#if message.done}
|
||||
<div class=" flex justify-start space-x-1 -mt-2">
|
||||
{#if message.parentId !== null && message.parentId in history.messages && (history.messages[message.parentId]?.childrenIds.length ?? 0) > 1}
|
||||
<div class="flex self-center">
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showPreviousMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-xs font-bold self-center">
|
||||
{history.messages[message.parentId].childrenIds.indexOf(
|
||||
message.id
|
||||
) + 1} / {history.messages[message.parentId].childrenIds.length}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showNextMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="{messageIdx + 1 === messages.length
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
editMessageHandler(message.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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="{messageIdx + 1 === messages.length
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition copy-response-button"
|
||||
on:click={() => {
|
||||
copyToClipboard(message.content);
|
||||
}}
|
||||
>
|
||||
<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="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="{messageIdx + 1 === messages.length
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
rateMessage(messageIdx, 1);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="{messageIdx + 1 === messages.length
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
rateMessage(messageIdx, -1);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="{messageIdx + 1 === messages.length
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
speakMessage(message.content);
|
||||
}}
|
||||
>
|
||||
<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="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if message.info}
|
||||
<button
|
||||
class=" {messageIdx + 1 === messages.length
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition whitespace-pre-wrap"
|
||||
on:click={() => {
|
||||
console.log(message);
|
||||
}}
|
||||
id="info-{message.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="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if messageIdx + 1 === messages.length}
|
||||
<button
|
||||
type="button"
|
||||
class="{messageIdx + 1 === messages.length
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={regenerateResponse}
|
||||
>
|
||||
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- {} -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
3
src/lib/components/chat/Messages/Name.svelte
Normal file
3
src/lib/components/chat/Messages/Name.svelte
Normal file
|
@ -0,0 +1,3 @@
|
|||
<div class=" self-center font-bold mb-0.5 capitalize">
|
||||
<slot />
|
||||
</div>
|
71
src/lib/components/chat/Messages/Placeholder.svelte
Normal file
71
src/lib/components/chat/Messages/Placeholder.svelte
Normal file
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let models = [];
|
||||
export let modelfiles = [];
|
||||
|
||||
let modelfile = null;
|
||||
let selectedModelIdx = 0;
|
||||
|
||||
$: modelfile =
|
||||
models[selectedModelIdx] in modelfiles ? modelfiles[models[selectedModelIdx]] : null;
|
||||
|
||||
$: if (models.length > 0) {
|
||||
selectedModelIdx = models.length - 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if models.length > 0}
|
||||
<div class="m-auto text-center max-w-md pb-56 px-2">
|
||||
<div class="flex justify-center mt-8">
|
||||
<div class="flex -space-x-10">
|
||||
{#each models as model, modelIdx}
|
||||
<button
|
||||
on:click={() => {
|
||||
selectedModelIdx = modelIdx;
|
||||
}}
|
||||
>
|
||||
{#if model in modelfiles}
|
||||
<img
|
||||
src={modelfiles[model]?.imageUrl}
|
||||
alt="modelfile"
|
||||
class=" w-20 mb-2 rounded-full {models.length > 1
|
||||
? ' border-[5px] border-white dark:border-gray-800'
|
||||
: ''}"
|
||||
draggable="false"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={models.length === 1 ? '/ollama.png' : 'ollama-dark.png'}
|
||||
class=" w-20 mb-2 {models.length === 1
|
||||
? 'invert-[10%] dark:invert-[100%]'
|
||||
: 'border-[5px] border-white dark:border-gray-800'} rounded-full"
|
||||
alt="ollama"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class=" mt-2 text-2xl text-gray-800 dark:text-gray-100 font-semibold">
|
||||
{#if modelfile}
|
||||
<span class=" capitalize">
|
||||
{modelfile.title}
|
||||
</span>
|
||||
<div class="mt-0.5 text-base font-normal text-gray-600 dark:text-gray-400">
|
||||
{modelfile.desc}
|
||||
</div>
|
||||
{#if modelfile.user}
|
||||
<div class="mt-0.5 text-sm font-normal text-gray-500 dark:text-gray-500">
|
||||
By <a href="https://ollamahub.com/m/{modelfile.user.username}"
|
||||
>{modelfile.user.name ? modelfile.user.name : `@${modelfile.user.username}`}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
How can I help you today?
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
7
src/lib/components/chat/Messages/ProfileImage.svelte
Normal file
7
src/lib/components/chat/Messages/ProfileImage.svelte
Normal file
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
export let src = '/user.png';
|
||||
</script>
|
||||
|
||||
<div class=" mr-4">
|
||||
<img {src} class=" max-w-[28px] object-cover rounded-full" alt="profile" draggable="false" />
|
||||
</div>
|
537
src/lib/components/chat/Messages/ResponseMessage.svelte
Normal file
537
src/lib/components/chat/Messages/ResponseMessage.svelte
Normal file
|
@ -0,0 +1,537 @@
|
|||
<script lang="ts">
|
||||
import { marked } from 'marked';
|
||||
|
||||
import tippy from 'tippy.js';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github-dark.min.css';
|
||||
import auto_render from 'katex/dist/contrib/auto-render.mjs';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import Name from './Name.svelte';
|
||||
import ProfileImage from './ProfileImage.svelte';
|
||||
import Skeleton from './Skeleton.svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
export let modelfiles = [];
|
||||
export let message;
|
||||
export let siblings;
|
||||
|
||||
export let isLastMessage = true;
|
||||
|
||||
export let confirmEditResponseMessage: Function;
|
||||
export let showPreviousMessage: Function;
|
||||
export let showNextMessage: Function;
|
||||
export let rateMessage: Function;
|
||||
|
||||
export let copyToClipboard: Function;
|
||||
export let regenerateResponse: Function;
|
||||
|
||||
let edit = false;
|
||||
let editedContent = '';
|
||||
|
||||
let tooltipInstance = null;
|
||||
let speaking = null;
|
||||
|
||||
$: if (message) {
|
||||
renderStyling();
|
||||
}
|
||||
|
||||
const renderStyling = async () => {
|
||||
await tick();
|
||||
|
||||
if (tooltipInstance) {
|
||||
tooltipInstance[0].destroy();
|
||||
}
|
||||
|
||||
renderLatex();
|
||||
hljs.highlightAll();
|
||||
createCopyCodeBlockButton();
|
||||
|
||||
if (message.info) {
|
||||
tooltipInstance = tippy(`#info-${message.id}`, {
|
||||
content: `<span class="text-xs" id="tooltip-${message.id}">token/s: ${
|
||||
`${
|
||||
Math.round(
|
||||
((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100
|
||||
) / 100
|
||||
} tokens` ?? 'N/A'
|
||||
}<br/>
|
||||
total_duration: ${
|
||||
Math.round(((message.info.total_duration ?? 0) / 1000000) * 100) / 100 ??
|
||||
'N/A'
|
||||
}ms<br/>
|
||||
load_duration: ${
|
||||
Math.round(((message.info.load_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
|
||||
}ms<br/>
|
||||
prompt_eval_count: ${message.info.prompt_eval_count ?? 'N/A'}<br/>
|
||||
prompt_eval_duration: ${
|
||||
Math.round(((message.info.prompt_eval_duration ?? 0) / 1000000) * 100) /
|
||||
100 ?? 'N/A'
|
||||
}ms<br/>
|
||||
eval_count: ${message.info.eval_count ?? 'N/A'}<br/>
|
||||
eval_duration: ${
|
||||
Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
|
||||
}ms</span>`,
|
||||
allowHTML: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createCopyCodeBlockButton = () => {
|
||||
// use a class selector if available
|
||||
let blocks = document.querySelectorAll('pre');
|
||||
|
||||
blocks.forEach((block) => {
|
||||
// only add button if browser supports Clipboard API
|
||||
|
||||
if (block.childNodes.length < 2 && block.id !== 'user-message') {
|
||||
let code = block.querySelector('code');
|
||||
code.style.borderTopRightRadius = 0;
|
||||
code.style.borderTopLeftRadius = 0;
|
||||
|
||||
let topBarDiv = document.createElement('div');
|
||||
topBarDiv.style.backgroundColor = '#202123';
|
||||
topBarDiv.style.overflowX = 'auto';
|
||||
topBarDiv.style.display = 'flex';
|
||||
topBarDiv.style.justifyContent = 'space-between';
|
||||
topBarDiv.style.padding = '0 1rem';
|
||||
topBarDiv.style.paddingTop = '4px';
|
||||
topBarDiv.style.borderTopRightRadius = '8px';
|
||||
topBarDiv.style.borderTopLeftRadius = '8px';
|
||||
|
||||
let langDiv = document.createElement('div');
|
||||
|
||||
let codeClassNames = code?.className.split(' ');
|
||||
langDiv.textContent =
|
||||
codeClassNames[0] === 'hljs' ? codeClassNames[1].slice(9) : codeClassNames[0].slice(9);
|
||||
langDiv.style.color = 'white';
|
||||
langDiv.style.margin = '4px';
|
||||
langDiv.style.fontSize = '0.75rem';
|
||||
|
||||
let button = document.createElement('button');
|
||||
button.className = 'copy-code-button';
|
||||
button.textContent = 'Copy Code';
|
||||
button.style.background = 'none';
|
||||
button.style.fontSize = '0.75rem';
|
||||
button.style.border = 'none';
|
||||
button.style.margin = '4px';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.color = '#ddd';
|
||||
button.addEventListener('click', () => copyCode(block, button));
|
||||
|
||||
topBarDiv.appendChild(langDiv);
|
||||
topBarDiv.appendChild(button);
|
||||
|
||||
block.prepend(topBarDiv);
|
||||
}
|
||||
});
|
||||
|
||||
async function copyCode(block, button) {
|
||||
let code = block.querySelector('code');
|
||||
let text = code.innerText;
|
||||
|
||||
await copyToClipboard(text);
|
||||
|
||||
// visual feedback that task is completed
|
||||
button.innerText = 'Copied!';
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerText = 'Copy Code';
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const renderLatex = () => {
|
||||
let chatMessageElements = document.getElementsByClassName('chat-assistant');
|
||||
// let lastChatMessageElement = chatMessageElements[chatMessageElements.length - 1];
|
||||
|
||||
for (const element of chatMessageElements) {
|
||||
auto_render(element, {
|
||||
// customised options
|
||||
// • auto-render specific keys, e.g.:
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
// { left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true }
|
||||
],
|
||||
// • rendering keys, e.g.:
|
||||
throwOnError: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSpeakMessage = async () => {
|
||||
if (speaking) {
|
||||
speechSynthesis.cancel();
|
||||
speaking = null;
|
||||
} else {
|
||||
speaking = true;
|
||||
const speak = new SpeechSynthesisUtterance(message.content);
|
||||
speechSynthesis.speak(speak);
|
||||
}
|
||||
};
|
||||
|
||||
const editMessageHandler = async () => {
|
||||
edit = true;
|
||||
editedContent = message.content;
|
||||
|
||||
await tick();
|
||||
const editElement = document.getElementById(`message-edit-${message.id}`);
|
||||
|
||||
editElement.style.height = '';
|
||||
editElement.style.height = `${editElement.scrollHeight}px`;
|
||||
};
|
||||
|
||||
const editMessageConfirmHandler = async () => {
|
||||
confirmEditResponseMessage(message.id, editedContent);
|
||||
|
||||
edit = false;
|
||||
editedContent = '';
|
||||
|
||||
await tick();
|
||||
renderStyling();
|
||||
};
|
||||
|
||||
const cancelEditMessage = async () => {
|
||||
edit = false;
|
||||
editedContent = '';
|
||||
await tick();
|
||||
renderStyling();
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
renderStyling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class=" flex w-full message-{message.id}">
|
||||
<ProfileImage src={modelfiles[message.model]?.imageUrl ?? '/favicon.png'} />
|
||||
|
||||
<div class="w-full overflow-hidden">
|
||||
<Name>
|
||||
{#if message.model in modelfiles}
|
||||
{modelfiles[message.model]?.title}
|
||||
{:else}
|
||||
Ollama <span class=" text-gray-500 text-sm font-medium"
|
||||
>{message.model ? ` ${message.model}` : ''}</span
|
||||
>
|
||||
{/if}
|
||||
</Name>
|
||||
|
||||
{#if message.content === ''}
|
||||
<Skeleton />
|
||||
{:else}
|
||||
<div
|
||||
class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:my-0 prose-p:-mb-4 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-6 prose-li:-mb-4 whitespace-pre-line"
|
||||
>
|
||||
<div>
|
||||
{#if edit === true}
|
||||
<div class=" w-full">
|
||||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
class=" bg-transparent outline-none w-full resize-none"
|
||||
bind:value={editedContent}
|
||||
on:input={(e) => {
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class=" mt-2 mb-1 flex justify-center space-x-2 text-sm font-medium">
|
||||
<button
|
||||
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded-lg"
|
||||
on:click={() => {
|
||||
editMessageConfirmHandler();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<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();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full">
|
||||
{#if message?.error === true}
|
||||
<div
|
||||
class="flex mt-2 mb-4 space-x-2 border px-4 py-3 border-red-800 bg-red-800/30 font-medium rounded-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 self-center"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class=" self-center">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@html marked(message.content.replace('\\\\', '\\\\\\'))}
|
||||
{/if}
|
||||
|
||||
{#if message.done}
|
||||
<div class=" flex justify-start space-x-1 -mt-2">
|
||||
{#if siblings.length > 1}
|
||||
<div class="flex self-center">
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showPreviousMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-xs font-bold self-center">
|
||||
{siblings.indexOf(message.id) + 1} / {siblings.length}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showNextMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="{isLastMessage
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
editMessageHandler();
|
||||
}}
|
||||
>
|
||||
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="{isLastMessage
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition copy-response-button"
|
||||
on:click={() => {
|
||||
copyToClipboard(message.content);
|
||||
}}
|
||||
>
|
||||
<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="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="{isLastMessage
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded {message.rating === 1
|
||||
? 'bg-gray-100 dark:bg-gray-900'
|
||||
: ''} transition"
|
||||
on:click={() => {
|
||||
rateMessage(message.id, 1);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="{isLastMessage
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded {message.rating === -1
|
||||
? 'bg-gray-100 dark:bg-gray-900'
|
||||
: ''} transition"
|
||||
on:click={() => {
|
||||
rateMessage(message.id, -1);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="{isLastMessage
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
toggleSpeakMessage(message);
|
||||
}}
|
||||
>
|
||||
{#if speaking}
|
||||
<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="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<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="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if message.info}
|
||||
<button
|
||||
class=" {isLastMessage
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition whitespace-pre-wrap"
|
||||
on:click={() => {
|
||||
console.log(message);
|
||||
}}
|
||||
id="info-{message.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="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isLastMessage}
|
||||
<button
|
||||
type="button"
|
||||
class="{isLastMessage
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={regenerateResponse}
|
||||
>
|
||||
<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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
19
src/lib/components/chat/Messages/Skeleton.svelte
Normal file
19
src/lib/components/chat/Messages/Skeleton.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<div class="w-full mt-3">
|
||||
<div class="animate-pulse flex w-full">
|
||||
<div class="space-y-2 w-full">
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" />
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4" />
|
||||
</div>
|
||||
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
195
src/lib/components/chat/Messages/UserMessage.svelte
Normal file
195
src/lib/components/chat/Messages/UserMessage.svelte
Normal file
|
@ -0,0 +1,195 @@
|
|||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import Name from './Name.svelte';
|
||||
import ProfileImage from './ProfileImage.svelte';
|
||||
|
||||
export let user;
|
||||
export let message;
|
||||
export let siblings;
|
||||
|
||||
export let confirmEditMessage: Function;
|
||||
export let showPreviousMessage: Function;
|
||||
export let showNextMessage: Function;
|
||||
export let copyToClipboard: Function;
|
||||
|
||||
let edit = false;
|
||||
let editedContent = '';
|
||||
|
||||
const editMessageHandler = async () => {
|
||||
edit = true;
|
||||
editedContent = message.content;
|
||||
|
||||
await tick();
|
||||
const editElement = document.getElementById(`message-edit-${message.id}`);
|
||||
|
||||
editElement.style.height = '';
|
||||
editElement.style.height = `${editElement.scrollHeight}px`;
|
||||
};
|
||||
|
||||
const editMessageConfirmHandler = async () => {
|
||||
confirmEditMessage(message.id, editedContent);
|
||||
|
||||
edit = false;
|
||||
editedContent = '';
|
||||
};
|
||||
|
||||
const cancelEditMessage = () => {
|
||||
edit = false;
|
||||
editedContent = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class=" flex w-full">
|
||||
<ProfileImage src={user?.profile_image_url ?? '/user.png'} />
|
||||
|
||||
<div class="w-full overflow-hidden">
|
||||
<Name>You</Name>
|
||||
|
||||
<div
|
||||
class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:my-0 prose-p:-mb-4 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-6 prose-li:-mb-4 whitespace-pre-line"
|
||||
>
|
||||
{#if message.files}
|
||||
<div class="my-3 w-full flex overflow-x-auto space-x-2">
|
||||
{#each message.files as file}
|
||||
<div>
|
||||
{#if file.type === 'image'}
|
||||
<img src={file.url} alt="input" class=" max-h-96 rounded-lg" draggable="false" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if edit === true}
|
||||
<div class=" w-full">
|
||||
<textarea
|
||||
id="message-edit-{message.id}"
|
||||
class=" bg-transparent outline-none w-full resize-none"
|
||||
bind:value={editedContent}
|
||||
on:input={(e) => {
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class=" mt-2 mb-1 flex justify-center space-x-2 text-sm font-medium">
|
||||
<button
|
||||
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-gray-100 transition rounded-lg"
|
||||
on:click={() => {
|
||||
editMessageConfirmHandler();
|
||||
}}
|
||||
>
|
||||
Save & Submit
|
||||
</button>
|
||||
|
||||
<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();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full">
|
||||
<pre id="user-message">{message.content}</pre>
|
||||
|
||||
<div class=" flex justify-start space-x-1">
|
||||
{#if siblings.length > 1}
|
||||
<div class="flex self-center">
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showPreviousMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-xs font-bold self-center">
|
||||
{siblings.indexOf(message.id) + 1} / {siblings.length}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
showNextMessage(message);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="invisible group-hover:visible p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
editMessageHandler();
|
||||
}}
|
||||
>
|
||||
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="invisible group-hover:visible p-1 rounded dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
copyToClipboard(message.content);
|
||||
}}
|
||||
>
|
||||
<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="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -6,7 +6,7 @@
|
|||
export let disabled = false;
|
||||
|
||||
const saveDefaultModel = () => {
|
||||
const hasEmptyModel = selectedModels.filter(it => it === '');
|
||||
const hasEmptyModel = selectedModels.filter((it) => it === '');
|
||||
if (hasEmptyModel.length) {
|
||||
toast.error('Choose a model before saving...');
|
||||
return;
|
||||
|
@ -88,8 +88,9 @@
|
|||
{#if selectedModelIdx === 0}
|
||||
<button
|
||||
class=" self-center dark:hover:text-gray-300"
|
||||
id="open-settings-button"
|
||||
on:click={async () => {
|
||||
await showSettings.set(true);
|
||||
await showSettings.set(!$showSettings);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { splitStream, getGravatarURL } from '$lib/utils';
|
||||
|
||||
import { getOllamaVersion } from '$lib/apis/ollama';
|
||||
import { createNewChat, getAllChats, getChatList } from '$lib/apis/chats';
|
||||
import { createNewChat, deleteAllChats, getAllChats, getChatList } from '$lib/apis/chats';
|
||||
import {
|
||||
WEB_UI_VERSION,
|
||||
OLLAMA_API_BASE_URL,
|
||||
|
@ -18,6 +18,8 @@
|
|||
|
||||
import Advanced from './Settings/Advanced.svelte';
|
||||
import Modal from '../common/Modal.svelte';
|
||||
import { updateUserPassword } from '$lib/apis/auths';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let show = false;
|
||||
|
||||
|
@ -82,7 +84,7 @@
|
|||
// Chats
|
||||
|
||||
let importFiles;
|
||||
let showDeleteHistoryConfirm = false;
|
||||
let showDeleteConfirm = false;
|
||||
|
||||
const importChats = async (_chats) => {
|
||||
for (const chat of _chats) {
|
||||
|
@ -113,11 +115,22 @@
|
|||
reader.readAsText(importFiles[0]);
|
||||
}
|
||||
|
||||
const deleteChats = async () => {
|
||||
await goto('/');
|
||||
await deleteAllChats(localStorage.token);
|
||||
await chats.set(await getChatList(localStorage.token));
|
||||
};
|
||||
|
||||
// Auth
|
||||
let authEnabled = false;
|
||||
let authType = 'Basic';
|
||||
let authContent = '';
|
||||
|
||||
// Account
|
||||
let currentPassword = '';
|
||||
let newPassword = '';
|
||||
let newPasswordConfirm = '';
|
||||
|
||||
// About
|
||||
let ollamaVersion = '';
|
||||
|
||||
|
@ -595,6 +608,31 @@
|
|||
return models;
|
||||
};
|
||||
|
||||
const updatePasswordHandler = async () => {
|
||||
if (newPassword === newPasswordConfirm) {
|
||||
const res = await updateUserPassword(localStorage.token, currentPassword, newPassword).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (res) {
|
||||
toast.success('Successfully updated.');
|
||||
}
|
||||
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
newPasswordConfirm = '';
|
||||
} else {
|
||||
toast.error(
|
||||
`The passwords you entered don't quite match. Please double-check and try again.`
|
||||
);
|
||||
newPassword = '';
|
||||
newPasswordConfirm = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
let settings = JSON.parse(localStorage.getItem('settings') ?? '{}');
|
||||
console.log(settings);
|
||||
|
@ -845,6 +883,32 @@
|
|||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
|
||||
'account'
|
||||
? 'bg-gray-200 dark:bg-gray-700'
|
||||
: ' hover:bg-gray-300 dark:hover:bg-gray-800'}"
|
||||
on:click={() => {
|
||||
selectedTab = 'account';
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0Zm-5-2a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM8 9c-1.825 0-3.422.977-4.295 2.437A5.49 5.49 0 0 0 8 13.5a5.49 5.49 0 0 0 4.294-2.063A4.997 4.997 0 0 0 8 9Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center">Account</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
|
||||
'about'
|
||||
|
@ -940,12 +1004,12 @@
|
|||
|
||||
<hr class=" dark:border-gray-700" />
|
||||
<div>
|
||||
<div class=" mb-2.5 text-sm font-medium">Ollama Server URL</div>
|
||||
<div class=" mb-2.5 text-sm font-medium">Ollama API URL</div>
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1 mr-2">
|
||||
<input
|
||||
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
|
||||
placeholder="Enter URL (e.g. http://localhost:11434/api)"
|
||||
placeholder="Enter URL (e.g. http://localhost:8080/ollama/api)"
|
||||
bind:value={API_BASE_URL}
|
||||
/>
|
||||
</div>
|
||||
|
@ -971,7 +1035,10 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
Trouble accessing Ollama? <a
|
||||
The field above should be set to <span
|
||||
class=" text-gray-500 dark:text-gray-300 font-medium">'/ollama/api'</span
|
||||
>;
|
||||
<a
|
||||
class=" text-gray-500 dark:text-gray-300 font-medium"
|
||||
href="https://github.com/ollama-webui/ollama-webui#troubleshooting"
|
||||
target="_blank"
|
||||
|
@ -1558,6 +1625,7 @@
|
|||
</form>
|
||||
{:else if selectedTab === 'chats'}
|
||||
<div class="flex flex-col h-full justify-between space-y-3 text-sm">
|
||||
<div class=" space-y-2">
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="chat-import-input"
|
||||
|
@ -1611,23 +1679,27 @@
|
|||
<div class=" self-center text-sm font-medium">Export Chats</div>
|
||||
</button>
|
||||
</div>
|
||||
<!-- {#if showDeleteHistoryConfirm}
|
||||
|
||||
<hr class=" dark:border-gray-700" />
|
||||
|
||||
{#if showDeleteConfirm}
|
||||
<div
|
||||
class="flex justify-between rounded-md items-center py-3 px-3.5 w-full transition"
|
||||
class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 mr-3"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Are you sure?</span>
|
||||
|
@ -1637,8 +1709,8 @@
|
|||
<button
|
||||
class="hover:text-white transition"
|
||||
on:click={() => {
|
||||
deleteChatHistory();
|
||||
showDeleteHistoryConfirm = false;
|
||||
deleteChats();
|
||||
showDeleteConfirm = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
@ -1657,7 +1729,7 @@
|
|||
<button
|
||||
class="hover:text-white transition"
|
||||
on:click={() => {
|
||||
showDeleteHistoryConfirm = false;
|
||||
showDeleteConfirm = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
@ -1675,30 +1747,32 @@
|
|||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
|
||||
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
on:click={() => {
|
||||
showDeleteHistoryConfirm = true;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<div class="mr-3">
|
||||
<div class=" self-center mr-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span>Clear conversations</span>
|
||||
<div class=" self-center text-sm font-medium">Delete All Chats</div>
|
||||
</button>
|
||||
{/if} -->
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if selectedTab === 'auth'}
|
||||
<form
|
||||
|
@ -1817,6 +1891,67 @@
|
|||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if selectedTab === 'account'}
|
||||
<form
|
||||
class="flex flex-col h-full text-sm"
|
||||
on:submit|preventDefault={() => {
|
||||
updatePasswordHandler();
|
||||
}}
|
||||
>
|
||||
<div class=" mb-2.5 font-medium">Change Password</div>
|
||||
|
||||
<div class=" space-y-1.5">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">Current Password</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
|
||||
type="password"
|
||||
bind:value={currentPassword}
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">New Password</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class=" mb-1 text-xs text-gray-500">Confirm Password</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
|
||||
type="password"
|
||||
bind:value={newPasswordConfirm}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
class=" px-4 py-2 text-xs bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-800 text-gray-100 transition rounded-md font-medium"
|
||||
>
|
||||
Update password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if selectedTab === 'about'}
|
||||
<div class="flex flex-col h-full justify-between space-y-3 text-sm mb-6">
|
||||
<div class=" space-y-3">
|
||||
|
|
|
@ -123,6 +123,23 @@
|
|||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-3 w-full self-start">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">Toggle settings</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">Toggle sidebar</div>
|
||||
|
||||
|
|
|
@ -160,6 +160,13 @@
|
|||
document.getElementById('delete-chat-button')?.click();
|
||||
}
|
||||
|
||||
// Check if Ctrl + . is pressed
|
||||
if (isCtrlPressed && event.key === '.') {
|
||||
event.preventDefault();
|
||||
console.log('openSettings');
|
||||
document.getElementById('open-settings-button')?.click();
|
||||
}
|
||||
|
||||
// Check if Ctrl + / is pressed
|
||||
if (isCtrlPressed && event.key === '/') {
|
||||
event.preventDefault();
|
||||
|
@ -249,6 +256,17 @@
|
|||
<br class=" hidden sm:flex" />(version
|
||||
<span class=" dark:text-white font-medium">{REQUIRED_OLLAMA_VERSION} or higher</span
|
||||
>) or check your connection.
|
||||
|
||||
<div class="mt-1 text-sm">
|
||||
Trouble accessing Ollama?
|
||||
<a
|
||||
class=" text-black dark:text-white font-semibold underline"
|
||||
href="https://github.com/ollama-webui/ollama-webui#troubleshooting"
|
||||
target="_blank"
|
||||
>
|
||||
Click here for help.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" mt-6 mx-auto relative group w-fit">
|
||||
|
|
|
@ -30,6 +30,17 @@
|
|||
? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
|
||||
: null;
|
||||
|
||||
let selectedModelfiles = {};
|
||||
$: selectedModelfiles = selectedModels.reduce((a, tagName, i, arr) => {
|
||||
const modelfile =
|
||||
$modelfiles.filter((modelfile) => modelfile.tagName === tagName)?.at(0) ?? undefined;
|
||||
|
||||
return {
|
||||
...a,
|
||||
...(modelfile && { [tagName]: modelfile })
|
||||
};
|
||||
}, {});
|
||||
|
||||
let chat = null;
|
||||
|
||||
let title = '';
|
||||
|
@ -609,7 +620,7 @@
|
|||
<Messages
|
||||
chatId={$chatId}
|
||||
{selectedModels}
|
||||
{selectedModelfile}
|
||||
{selectedModelfiles}
|
||||
bind:history
|
||||
bind:messages
|
||||
bind:autoScroll
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import toast from 'svelte-french-toast';
|
||||
|
||||
import { updateUserRole, getUsers } from '$lib/apis/users';
|
||||
import { updateUserRole, getUsers, deleteUserById } from '$lib/apis/users';
|
||||
|
||||
let loaded = false;
|
||||
let users = [];
|
||||
|
@ -22,6 +22,16 @@
|
|||
}
|
||||
};
|
||||
|
||||
const deleteUserHandler = async (id) => {
|
||||
const res = await deleteUserById(localStorage.token, id).catch((error) => {
|
||||
toast.error(error);
|
||||
return null;
|
||||
});
|
||||
if (res) {
|
||||
users = await getUsers(localStorage.token);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if ($user?.role !== 'admin') {
|
||||
await goto('/');
|
||||
|
@ -55,7 +65,7 @@
|
|||
<th scope="col" class="px-6 py-3"> Name </th>
|
||||
<th scope="col" class="px-6 py-3"> Email </th>
|
||||
<th scope="col" class="px-6 py-3"> Role </th>
|
||||
<!-- <th scope="col" class="px-6 py-3"> Action </th> -->
|
||||
<th scope="col" class="px-6 py-3"> Action </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -63,15 +73,16 @@
|
|||
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
|
||||
<th
|
||||
scope="row"
|
||||
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
|
||||
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white w-fit"
|
||||
>
|
||||
<div class="flex flex-row">
|
||||
<img
|
||||
class=" rounded-full max-w-[30px] max-h-[30px] object-cover mr-4"
|
||||
src={user.profile_image_url}
|
||||
alt="user"
|
||||
/>
|
||||
|
||||
<div class=" font-semibold md:self-center">{user.name}</div>
|
||||
<div class=" font-semibold self-center">{user.name}</div>
|
||||
</div>
|
||||
</th>
|
||||
<td class="px-6 py-4"> {user.email} </td>
|
||||
|
@ -89,9 +100,29 @@
|
|||
}}>{user.role}</button
|
||||
>
|
||||
</td>
|
||||
<!-- <td class="px-6 py-4 text-center">
|
||||
<button class=" text-white underline"> Edit </button>
|
||||
</td> -->
|
||||
<td class="px-6 py-4 text-center flex justify-center">
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 border dark:border-gray-600 rounded-xl flex"
|
||||
on:click={async () => {
|
||||
deleteUserHandler(user.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 9l-.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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
|
|
@ -31,6 +31,17 @@
|
|||
? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
|
||||
: null;
|
||||
|
||||
let selectedModelfiles = {};
|
||||
$: selectedModelfiles = selectedModels.reduce((a, tagName, i, arr) => {
|
||||
const modelfile =
|
||||
$modelfiles.filter((modelfile) => modelfile.tagName === tagName)?.at(0) ?? undefined;
|
||||
|
||||
return {
|
||||
...a,
|
||||
...(modelfile && { [tagName]: modelfile })
|
||||
};
|
||||
}, {});
|
||||
|
||||
let chat = null;
|
||||
|
||||
let title = '';
|
||||
|
@ -643,7 +654,7 @@
|
|||
<Messages
|
||||
chatId={$chatId}
|
||||
{selectedModels}
|
||||
{selectedModelfile}
|
||||
{selectedModelfiles}
|
||||
bind:history
|
||||
bind:messages
|
||||
bind:autoScroll
|
||||
|
|
BIN
static/ollama-dark.png
Normal file
BIN
static/ollama-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.6 KiB |
Loading…
Reference in a new issue