mirror of
https://github.com/informaticker/cchat.git
synced 2024-11-23 10:11:55 +01:00
Initial commit
This commit is contained in:
commit
af38cda43e
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Use an official Python runtime as the base image
|
||||||
|
FROM python:3.9-slim
|
||||||
|
|
||||||
|
# Set the working directory in the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the requirements file into the container
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install the required packages
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Make port 5000 available to the world outside this container
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Define environment variable for Flask
|
||||||
|
ENV FLASK_APP=app.py
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]
|
10
README.md
Normal file
10
README.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# cchat
|
||||||
|
language model interface
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
To run the application in a docker container, first customize the docker-compose file.
|
||||||
|
|
||||||
|
Then run the following command:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
73
app.py
Normal file
73
app.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
from flask import Flask, request, jsonify, render_template, Response, stream_with_context
|
||||||
|
from transformers import AutoTokenizer
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Initialize rate limiter
|
||||||
|
limiter = Limiter(
|
||||||
|
get_remote_address,
|
||||||
|
app=app,
|
||||||
|
storage_uri="memory://"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load the tokenizer
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained("informatiker/Llama-3-8B-Instruct-abliterated")
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(os.environ.get('TOKENIZER', 'gpt2'))
|
||||||
|
|
||||||
|
api_url = os.environ.get('API_URL', 'https://api.openai.com/v1')
|
||||||
|
api_key = os.environ.get('API_KEY')
|
||||||
|
api_model = os.environ.get('API_MODEL', 'gpt-3.5-turbo')
|
||||||
|
temperature = os.environ.get('TEMPERATURE', 0)
|
||||||
|
|
||||||
|
@app.route('/v1/tokenizer/count', methods=['POST'])
|
||||||
|
def token_count():
|
||||||
|
data = request.json
|
||||||
|
messages = data.get('messages', [])
|
||||||
|
full_text = " ".join([f"{msg['role']}: {msg['content']}" for msg in messages])
|
||||||
|
tokens = tokenizer.encode(full_text)
|
||||||
|
token_count = len(tokens)
|
||||||
|
return jsonify({"token_count": token_count})
|
||||||
|
|
||||||
|
@app.route('/v1/chat/completions', methods=['POST'])
|
||||||
|
@limiter.limit(os.environ.get('RATE_LIMIT', '20/minute'))
|
||||||
|
def proxy_chat_completions():
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
request_data = request.json
|
||||||
|
|
||||||
|
request_data['model'] = api_model
|
||||||
|
request_data['temperature'] = temperature
|
||||||
|
|
||||||
|
request_data['stream'] = True
|
||||||
|
|
||||||
|
response = requests.post(f"{api_url}/chat/completions",
|
||||||
|
json=request_data,
|
||||||
|
headers=headers,
|
||||||
|
stream=True)
|
||||||
|
|
||||||
|
# Stream the response back to the client
|
||||||
|
def generate():
|
||||||
|
for chunk in response.iter_content(chunk_size=8):
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return Response(stream_with_context(generate()),
|
||||||
|
content_type=response.headers['content-type'])
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/static/<path:filename>')
|
||||||
|
def serve_static(filename):
|
||||||
|
return app.send_static_file(filename)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(debug=False, port=5000)
|
15
docker-compose.yaml
Normal file
15
docker-compose.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- FLASK_APP=app.py
|
||||||
|
- TOKENIZER=gpt2
|
||||||
|
- API_URL=https://api.openai.com/v1
|
||||||
|
- API_KEY=your_api_key_here
|
||||||
|
- API_MODEL=gpt-3.5-turbo
|
||||||
|
- TEMPERATURE=0
|
||||||
|
- RATE_LIMIT=20/minute
|
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
Flask==2.1.0
|
||||||
|
transformers==4.28.1
|
||||||
|
requests==2.26.0
|
||||||
|
Flask-Limiter==3.1.0
|
||||||
|
gunicorn==20.1.0
|
172
static/css/common.css
Normal file
172
static/css/common.css
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
/* make it responsive */
|
||||||
|
@media (min-width: 852px) {
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 852px) {
|
||||||
|
body {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* resets */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--primary-bg-color);
|
||||||
|
color: var(--foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0em;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
width: 92%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
}
|
||||||
|
button:active {
|
||||||
|
}
|
||||||
|
|
||||||
|
/* components */
|
||||||
|
.container {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-w-only {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-h-only {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-container {
|
||||||
|
width: 96vw;
|
||||||
|
height: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clean-a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: #006fc1;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: #228039;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-horizontal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-separator {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: var(--tertiary-bg-color);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code {
|
||||||
|
background-color: var(--tertiary-bg-color);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--foreground-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-button:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
368
static/css/index.css
Normal file
368
static/css/index.css
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #ff71ce;
|
||||||
|
--primary-color-transparent: #ff71ce66;
|
||||||
|
--secondary-color: #01cdfe;
|
||||||
|
--secondary-color-transparent: #01cdfe66;
|
||||||
|
|
||||||
|
--red-color: #ff71ce;
|
||||||
|
--green-color: #05ffa1;
|
||||||
|
--silver-color: #b967ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--primary-bg-color: #fffcf9;
|
||||||
|
--secondary-bg-color: #ffd1dc;
|
||||||
|
--tertiary-bg-color: #fbcff3;
|
||||||
|
--foreground-color: #2d0036;
|
||||||
|
--accent-color: #b967ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--primary-bg-color: #16001e;
|
||||||
|
--secondary-bg-color: #2d0036;
|
||||||
|
--tertiary-bg-color: #3d004d;
|
||||||
|
--foreground-color: #fffcf9;
|
||||||
|
--accent-color: #05ffa1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--primary-bg-color),
|
||||||
|
var(--secondary-bg-color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home {
|
||||||
|
width: 100%;
|
||||||
|
height: 90%;
|
||||||
|
margin-bottom: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
margin-top: 3rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-shadow: 2px 2px var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.histories-container-container {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 75%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histories-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histories-start,
|
||||||
|
.histories-end {
|
||||||
|
height: 3rem;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 999;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.histories-start {
|
||||||
|
top: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
var(--primary-bg-color) 0%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.histories-end {
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
var(--primary-bg-color) 0%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history {
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 40rem;
|
||||||
|
background-color: var(--tertiary-bg-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 2px solid var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateX(calc(1px * var(--tx, 0)));
|
||||||
|
opacity: var(--opacity, 1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history:hover {
|
||||||
|
background-color: var(--secondary-bg-color);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-delete-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--foreground-color);
|
||||||
|
border-radius: 0 0 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-delete-button:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 11rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
width: 96%;
|
||||||
|
max-width: 80rem;
|
||||||
|
display: grid;
|
||||||
|
background-color: var(--secondary-bg-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-role-assistant {
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
border-left: 2px solid var(--primary-color);
|
||||||
|
box-shadow: -10px 10px 20px 2px var(--primary-color-transparent);
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-role-user {
|
||||||
|
border-bottom: 2px solid var(--secondary-color);
|
||||||
|
border-right: 2px solid var(--secondary-color);
|
||||||
|
box-shadow: 10px 10px 20px 2px var(--secondary-color-transparent);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message > pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background-color: var(--tertiary-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--foreground-color);
|
||||||
|
border-radius: 0 0 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-button:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
0deg,
|
||||||
|
var(--primary-bg-color) 55%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-performance {
|
||||||
|
margin-top: 4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-performance-point {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-performance-point > p {
|
||||||
|
height: 1rem;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 90%;
|
||||||
|
min-height: 3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-form {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 3rem;
|
||||||
|
max-height: 8rem;
|
||||||
|
background-color: var(--tertiary-bg-color);
|
||||||
|
color: var(--foreground-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-form:focus {
|
||||||
|
box-shadow: 0 0 10px var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-button {
|
||||||
|
height: 3rem;
|
||||||
|
width: 4rem;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--foreground-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-button:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-button:disabled {
|
||||||
|
background-color: var(--secondary-bg-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.megrim-regular {
|
||||||
|
font-family: "Megrim", system-ui;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monospace {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.new-chat-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
color: var(--foreground-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-button i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-text {
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
max-width 0.3s ease,
|
||||||
|
opacity 0.3s ease,
|
||||||
|
margin-left 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-button:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
width: auto;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-button:hover .new-chat-text {
|
||||||
|
max-width: 100px;
|
||||||
|
opacity: 1;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-chat-button:hover i {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
width: 2rem;
|
||||||
|
}
|
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
175
static/js/index.js
Normal file
175
static/js/index.js
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("state", () => ({
|
||||||
|
// current state
|
||||||
|
cstate: {
|
||||||
|
time: null,
|
||||||
|
messages: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// historical state
|
||||||
|
histories: JSON.parse(localStorage.getItem("histories")) || [],
|
||||||
|
|
||||||
|
home: 0,
|
||||||
|
generating: false,
|
||||||
|
endpoint: window.location.origin + "/v1",
|
||||||
|
model: "llama3-8b-8192", // This doesen't matter anymore as the backend handles it now
|
||||||
|
stopToken: "<|eot_id|>", // We may need this for some models
|
||||||
|
|
||||||
|
// performance tracking
|
||||||
|
time_till_first: 0,
|
||||||
|
tokens_per_second: 0,
|
||||||
|
total_tokens: 0,
|
||||||
|
|
||||||
|
removeHistory(cstate) {
|
||||||
|
const index = this.histories.findIndex((state) => {
|
||||||
|
return state.time === cstate.time;
|
||||||
|
});
|
||||||
|
if (index !== -1) {
|
||||||
|
this.histories.splice(index, 1);
|
||||||
|
localStorage.setItem("histories", JSON.stringify(this.histories));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleSend() {
|
||||||
|
const el = document.getElementById("input-form");
|
||||||
|
const value = el.value.trim();
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
if (this.generating) return;
|
||||||
|
this.generating = true;
|
||||||
|
if (this.home === 0) this.home = 1;
|
||||||
|
|
||||||
|
// ensure that going back in history will go back to home
|
||||||
|
window.history.pushState({}, "", "/");
|
||||||
|
|
||||||
|
// add message to list
|
||||||
|
this.cstate.messages.push({ role: "user", content: value });
|
||||||
|
|
||||||
|
// clear textarea
|
||||||
|
el.value = "";
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = el.scrollHeight + "px";
|
||||||
|
|
||||||
|
// reset performance tracking
|
||||||
|
const prefill_start = Date.now();
|
||||||
|
let start_time = 0;
|
||||||
|
let tokens = 0;
|
||||||
|
this.tokens_per_second = 0;
|
||||||
|
|
||||||
|
// start receiving server sent events
|
||||||
|
let gottenFirstChunk = false;
|
||||||
|
for await (const chunk of this.openaiChatCompletion(
|
||||||
|
this.cstate.messages,
|
||||||
|
)) {
|
||||||
|
if (!gottenFirstChunk) {
|
||||||
|
this.cstate.messages.push({ role: "assistant", content: "" });
|
||||||
|
gottenFirstChunk = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add chunk to the last message
|
||||||
|
this.cstate.messages[this.cstate.messages.length - 1].content += chunk;
|
||||||
|
|
||||||
|
// calculate performance tracking
|
||||||
|
tokens += 1;
|
||||||
|
this.total_tokens += 1;
|
||||||
|
if (start_time === 0) {
|
||||||
|
start_time = Date.now();
|
||||||
|
this.time_till_first = start_time - prefill_start;
|
||||||
|
} else {
|
||||||
|
const diff = Date.now() - start_time;
|
||||||
|
if (diff > 0) {
|
||||||
|
this.tokens_per_second = tokens / (diff / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the state in histories or add it if it doesn't exist
|
||||||
|
const index = this.histories.findIndex((cstate) => {
|
||||||
|
return cstate.time === this.cstate.time;
|
||||||
|
});
|
||||||
|
this.cstate.time = Date.now();
|
||||||
|
if (index !== -1) {
|
||||||
|
// update the time
|
||||||
|
this.histories[index] = this.cstate;
|
||||||
|
} else {
|
||||||
|
this.histories.push(this.cstate);
|
||||||
|
}
|
||||||
|
// update in local storage
|
||||||
|
localStorage.setItem("histories", JSON.stringify(this.histories));
|
||||||
|
|
||||||
|
this.generating = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleEnter(event) {
|
||||||
|
// if shift is not pressed
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
await this.handleSend();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTotalTokens(messages) {
|
||||||
|
fetch(`${window.location.origin}/v1/tokenizer/count`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages }),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
this.total_tokens = data.token_count;
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
},
|
||||||
|
|
||||||
|
async *openaiChatCompletion(messages) {
|
||||||
|
// stream response
|
||||||
|
const response = await fetch(`${this.endpoint}/chat/completions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
messages: messages,
|
||||||
|
stream: true,
|
||||||
|
stop: [this.stopToken],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === "[DONE]") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (json.choices && json.choices[0].delta.content) {
|
||||||
|
yield json.choices[0].delta.content;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing JSON:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
178
templates/index.html
Normal file
178
templates/index.html
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>cchat</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}" type="image/svg+xml">
|
||||||
|
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/@alpine-collective/toolkit@1.0.2/dist/cdn.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script defer src="https://unpkg.com/@marcreichel/alpine-autosize@1.3.x/dist/alpine-autosize.min.js"></script>
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/dompurify@3.1.5/dist/purify.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/marked@13.0.0/marked.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/marked-highlight@2.1.2/lib/index.umd.js"></script>
|
||||||
|
<script src="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js"></script>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Megrim&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/base-min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||||
|
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
|
||||||
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/vs2015.min.css">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/common.css') }}">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main x-data="state" x-init="console.log(endpoint)">
|
||||||
|
<button class="new-chat-button" @click="
|
||||||
|
home = 0;
|
||||||
|
cstate = { time: null, messages: [] };
|
||||||
|
time_till_first = 0;
|
||||||
|
tokens_per_second = 0;
|
||||||
|
total_tokens = 0;
|
||||||
|
">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
<span class="new-chat-text">New Chat</span>
|
||||||
|
</button>
|
||||||
|
<div class="home centered" x-show="home === 0" x-transition x-effect="
|
||||||
|
$refs.inputForm.focus();
|
||||||
|
if (home === 1) setTimeout(() => home = 2, 100);
|
||||||
|
if (home === -1) setTimeout(() => home = 0, 100);
|
||||||
|
" @popstate.window="
|
||||||
|
if (home === 2) {
|
||||||
|
home = -1;
|
||||||
|
cstate = { time: null, messages: [] };
|
||||||
|
time_till_first = 0;
|
||||||
|
tokens_per_second = 0;
|
||||||
|
total_tokens = 0;
|
||||||
|
}
|
||||||
|
">
|
||||||
|
<h1 class="title megrim-regular">cchat</h1>
|
||||||
|
<div class="histories-container-container">
|
||||||
|
<template x-if="histories.length">
|
||||||
|
<div class="histories-start"></div>
|
||||||
|
</template>
|
||||||
|
<div class="histories-container" x-intersect="
|
||||||
|
$el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
">
|
||||||
|
<template x-for="_state in histories.toSorted((a, b) => b.time - a.time)">
|
||||||
|
<div x-data="{ otx: 0, trigger: 75 }" class="history" @click="
|
||||||
|
cstate = _state;
|
||||||
|
updateTotalTokens(cstate.messages);
|
||||||
|
home = 1;
|
||||||
|
// ensure that going back in history will go back to home
|
||||||
|
window.history.pushState({}, '', '/');
|
||||||
|
" @touchstart="
|
||||||
|
otx = $event.changedTouches[0].clientX;
|
||||||
|
" @touchmove="
|
||||||
|
$el.style.setProperty('--tx', $event.changedTouches[0].clientX - otx);
|
||||||
|
$el.style.setProperty('--opacity', 1 - (Math.abs($event.changedTouches[0].clientX - otx) / trigger));
|
||||||
|
" @touchend="
|
||||||
|
if (Math.abs($event.changedTouches[0].clientX - otx) > trigger) removeHistory(_state);
|
||||||
|
$el.style.setProperty('--tx', 0);
|
||||||
|
$el.style.setProperty('--opacity', 1);
|
||||||
|
">
|
||||||
|
<h3 x-text="new Date(_state.time).toLocaleString()"></h3>
|
||||||
|
<p x-text="$truncate(_state.messages[0].content, 80)"></p>
|
||||||
|
<!-- delete button -->
|
||||||
|
<button class="history-delete-button" @click.stop="removeHistory(_state);">
|
||||||
|
<i class=" fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<template x-if="histories.length">
|
||||||
|
<div class="histories-end"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-ref="messages" class="messages" x-init="
|
||||||
|
$watch('cstate', value => {
|
||||||
|
$el.innerHTML = '';
|
||||||
|
value.messages.forEach(({ role, content }) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `message message-role-${role}`;
|
||||||
|
try {
|
||||||
|
div.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
||||||
|
} catch (e) {
|
||||||
|
console.log(content);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add a clipboard button to all code blocks
|
||||||
|
const codeBlocks = div.querySelectorAll('pre code');
|
||||||
|
codeBlocks.forEach(codeBlock => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'clipboard-button';
|
||||||
|
button.innerHTML = '<i class=\'fas fa-clipboard\'></i>';
|
||||||
|
button.onclick = () => {
|
||||||
|
navigator.clipboard.writeText(codeBlock.textContent);
|
||||||
|
button.innerHTML = '<i class=\'fas fa-check\'></i>';
|
||||||
|
setTimeout(() => button.innerHTML = '<i class=\'fas fa-clipboard\'></i>', 1000);
|
||||||
|
};
|
||||||
|
codeBlock.parentNode.insertBefore(button, codeBlock);
|
||||||
|
});
|
||||||
|
|
||||||
|
$el.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
$el.scrollTo({ top: $el.scrollHeight, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
" x-intersect="
|
||||||
|
$el.scrollTo({ top: $el.scrollHeight, behavior: 'smooth' });
|
||||||
|
" x-show="home === 2" x-transition>
|
||||||
|
</div> <div class="input-container">
|
||||||
|
<div class="input-performance">
|
||||||
|
<span class="input-performance-point">
|
||||||
|
<p class="monospace" x-text="time_till_first"></p>
|
||||||
|
<p class="megrim-regular">TTFT</p>
|
||||||
|
</span>
|
||||||
|
<span class="input-performance-point">
|
||||||
|
<p class="monospace" x-text="tokens_per_second.toFixed(0)"></p>
|
||||||
|
<p class="megrim-regular">TOKENS/SEC</p>
|
||||||
|
</span>
|
||||||
|
<span class="input-performance-point">
|
||||||
|
<p class="monospace" x-text="total_tokens"></p>
|
||||||
|
<p class="megrim-regular">TOKENS</p>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input">
|
||||||
|
<textarea x-ref="inputForm" id="input-form" class="input-form" autofocus rows=1 x-autosize
|
||||||
|
:placeholder="generating ? 'Generating...' : 'Say something'" :disabled="generating" @input="
|
||||||
|
home = (home === 0) ? 1 : home
|
||||||
|
if (cstate.messages.length === 0 && $el.value === '') home = -1;
|
||||||
|
|
||||||
|
if ($el.value !== '') {
|
||||||
|
const messages = [...cstate.messages];
|
||||||
|
messages.push({ role: 'user', content: $el.value });
|
||||||
|
updateTotalTokens(messages);
|
||||||
|
} else {
|
||||||
|
if (cstate.messages.length === 0) total_tokens = 0;
|
||||||
|
else updateTotalTokens(cstate.messages);
|
||||||
|
}
|
||||||
|
" x-effect="
|
||||||
|
console.log(generating);
|
||||||
|
if (!generating) $nextTick(() => {
|
||||||
|
$el.focus();
|
||||||
|
setTimeout(() => $refs.messages.scrollTo({ top: $refs.messages.scrollHeight, behavior: 'smooth' }), 100);
|
||||||
|
});
|
||||||
|
" @keydown.enter="await handleEnter($event)" @keydown.escape.window="$focus.focus($el)"></textarea>
|
||||||
|
<button class="input-button" :disabled="generating" @click="await handleSend()">
|
||||||
|
<i class="fas" :class="generating ? 'fa-spinner fa-spin' : 'fa-paper-plane'"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user