cleanup styling, show reasoning, fixes & improvements
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
.env
|
.env
|
||||||
|
debug.json
|
22
chat.go
@@ -6,16 +6,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/revrost/go-openrouter"
|
"github.com/revrost/go-openrouter"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
@@ -24,7 +26,8 @@ type Request struct {
|
|||||||
func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
|
func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
|
||||||
var request openrouter.ChatCompletionRequest
|
var request openrouter.ChatCompletionRequest
|
||||||
|
|
||||||
if _, ok := ModelMap[r.Model]; !ok {
|
model, ok := ModelMap[r.Model]
|
||||||
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown model: %q", r.Model)
|
return nil, fmt.Errorf("unknown model: %q", r.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,15 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
|
|||||||
|
|
||||||
request.Temperature = float32(r.Temperature)
|
request.Temperature = float32(r.Temperature)
|
||||||
|
|
||||||
|
prompt, err := BuildPrompt(r.Prompt, model)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if prompt != "" {
|
||||||
|
request.Messages = append(request.Messages, openrouter.SystemMessage(prompt))
|
||||||
|
}
|
||||||
|
|
||||||
for index, message := range r.Messages {
|
for index, message := range r.Messages {
|
||||||
if message.Role != openrouter.ChatMessageRoleSystem && message.Role != openrouter.ChatMessageRoleAssistant && message.Role != openrouter.ChatMessageRoleUser {
|
if message.Role != openrouter.ChatMessageRoleSystem && message.Role != openrouter.ChatMessageRoleAssistant && message.Role != openrouter.ChatMessageRoleUser {
|
||||||
return nil, fmt.Errorf("[%d] invalid role: %q", index+1, message.Role)
|
return nil, fmt.Errorf("[%d] invalid role: %q", index+1, message.Role)
|
||||||
@@ -44,7 +56,7 @@ func (r *Request) Parse() (*openrouter.ChatCompletionRequest, error) {
|
|||||||
request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{
|
request.Messages = append(request.Messages, openrouter.ChatCompletionMessage{
|
||||||
Role: message.Role,
|
Role: message.Role,
|
||||||
Content: openrouter.Content{
|
Content: openrouter.Content{
|
||||||
Text: message.Content,
|
Text: message.Text,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -74,6 +86,10 @@ func HandleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
request.Stream = true
|
request.Stream = true
|
||||||
|
|
||||||
|
// DEBUG
|
||||||
|
b, _ := json.MarshalIndent(request, "", "\t")
|
||||||
|
os.WriteFile("debug.json", b, 0755)
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
stream, err := OpenRouterStartStream(ctx, *request)
|
stream, err := OpenRouterStartStream(ctx, *request)
|
||||||
|
51
prompts.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PromptData struct {
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed prompts/normal.txt
|
||||||
|
PromptNormal string
|
||||||
|
|
||||||
|
PromptNormalTmpl = template.Must(template.New("normal").Parse(PromptNormal))
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildPrompt(name string, model *Model) (string, error) {
|
||||||
|
if name == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmpl *template.Template
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "normal":
|
||||||
|
tmpl = PromptNormalTmpl
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown prompt: %q", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := tmpl.Execute(&buf, PromptData{
|
||||||
|
Name: model.Name,
|
||||||
|
Slug: model.ID,
|
||||||
|
Date: time.Now().Format(time.RFC1123),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
16
prompts/normal.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
You are {{.Name}}, an AI assistant created to be helpful, harmless, and honest. Today's date is {{.Date}}.
|
||||||
|
|
||||||
|
Your responses should be accurate and informative. When answering questions, be direct and helpful without unnecessary preambles or acknowledgments of instructions.
|
||||||
|
|
||||||
|
Use markdown formatting when it improves clarity:
|
||||||
|
- **Bold** for emphasis
|
||||||
|
- *Italics* for softer emphasis
|
||||||
|
- `Code` for inline code
|
||||||
|
- Triple backticks for code blocks with language identifiers
|
||||||
|
- Tables using pipe syntax when presenting structured data
|
||||||
|
|
||||||
|
Be conversational and adapt to the user's tone. Ask clarifying questions when requests are ambiguous. If you cannot answer something due to knowledge limitations, say so directly.
|
||||||
|
|
||||||
|
When asked about your identity, you are {{.Name}}. You don't have access to real-time information or the ability to browse the internet, so for current events or time-sensitive information, acknowledge this limitation.
|
||||||
|
|
||||||
|
Focus on being genuinely helpful while maintaining natural conversation flow.
|
137
static/chat.css
@@ -23,7 +23,6 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 25px;
|
gap: 25px;
|
||||||
background: #1e2030;
|
background: #1e2030;
|
||||||
box-shadow: 0px 0px 4px 4px #1e2030;
|
|
||||||
margin: auto;
|
margin: auto;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -57,7 +56,6 @@ body {
|
|||||||
#message,
|
#message,
|
||||||
.message {
|
.message {
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: 0px 0px 4px 4px #24273a;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #24273a;
|
background: #24273a;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -69,6 +67,7 @@ body {
|
|||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
|
padding-top: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.user {
|
.message.user {
|
||||||
@@ -83,8 +82,8 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
line-height: 12px;
|
line-height: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
top: 4px;
|
top: 6px;
|
||||||
left: 4px;
|
left: 6px;
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +108,11 @@ body {
|
|||||||
background-image: url(icons/assistant.svg);
|
background-image: url(icons/assistant.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message .reasoning,
|
||||||
.message .text {
|
.message .text {
|
||||||
display: block;
|
display: block;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 10px 12px;
|
padding: 8px 12px;
|
||||||
padding-top: 28px;
|
|
||||||
white-space: pre-line;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +121,62 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message:not(.expanded) .reasoning-text {
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.expanded .reasoning-text {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #1e2030;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:not(.has-reasoning) .reasoning {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning .toggle {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning .toggle::after,
|
||||||
|
.reasoning .toggle::before {
|
||||||
|
content: "";
|
||||||
|
background-image: url(icons/reasoning.svg);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning .toggle::after {
|
||||||
|
background-image: url(icons/chevron.svg);
|
||||||
|
left: unset;
|
||||||
|
right: -2px;
|
||||||
|
top: 1px;
|
||||||
|
transition: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.expanded .reasoning .toggle::after {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown p {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
border: none;
|
border: none;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -169,46 +223,47 @@ textarea {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message.reasoning .reasoning button.toggle::before {
|
||||||
|
animation: rotating 2s linear infinite;
|
||||||
|
background-image: url(icons/loading.svg);
|
||||||
|
}
|
||||||
|
|
||||||
.message .text::before {
|
.message .text::before {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message:empty.receiving .text::before,
|
||||||
.message.waiting .text::before {
|
.message.waiting .text::before {
|
||||||
content: "waiting...";
|
content: ". . .";
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.reasoning .text::before {
|
button,
|
||||||
content: "reasoning...";
|
input,
|
||||||
}
|
select {
|
||||||
|
border: none;
|
||||||
.message:empty.receiving .text::before {
|
font: inherit;
|
||||||
content: "receiving...";
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
background: #363a4f;
|
||||||
|
}
|
||||||
|
|
||||||
#chat button {
|
#chat button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
border: none;
|
|
||||||
background: #363a4f;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#chat .options {
|
#chat .options {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
left: 12px;
|
left: 20px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -230,11 +285,14 @@ select {
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reasoning .toggle::before,
|
||||||
|
.reasoning .toggle::after,
|
||||||
#bottom,
|
#bottom,
|
||||||
.message .role::before,
|
.message .role::before,
|
||||||
#clear,
|
#clear,
|
||||||
#add,
|
#add,
|
||||||
#send,
|
#send,
|
||||||
|
.message .copy,
|
||||||
.message .edit,
|
.message .edit,
|
||||||
.message .delete,
|
.message .delete,
|
||||||
#chat .option label {
|
#chat .option label {
|
||||||
@@ -246,6 +304,14 @@ select {
|
|||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message .copy {
|
||||||
|
background-image: url(icons/copy.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message .copy.copied {
|
||||||
|
background-image: url(icons/check.svg);
|
||||||
|
}
|
||||||
|
|
||||||
.message .edit {
|
.message .edit {
|
||||||
background-image: url(icons/edit.svg);
|
background-image: url(icons/edit.svg);
|
||||||
}
|
}
|
||||||
@@ -265,7 +331,6 @@ select {
|
|||||||
#model {
|
#model {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#temperature {
|
#temperature {
|
||||||
@@ -283,6 +348,10 @@ label[for="model"] {
|
|||||||
background-image: url(icons/model.svg);
|
background-image: url(icons/model.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label[for="prompt"] {
|
||||||
|
background-image: url(icons/prompt.svg);
|
||||||
|
}
|
||||||
|
|
||||||
label[for="temperature"] {
|
label[for="temperature"] {
|
||||||
background-image: url(icons/temperature.svg);
|
background-image: url(icons/temperature.svg);
|
||||||
}
|
}
|
||||||
@@ -300,7 +369,7 @@ label[for="temperature"] {
|
|||||||
#add,
|
#add,
|
||||||
#send {
|
#send {
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
right: 12px;
|
right: 20px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
background-image: url(icons/send.svg);
|
background-image: url(icons/send.svg);
|
||||||
@@ -308,7 +377,7 @@ label[for="temperature"] {
|
|||||||
|
|
||||||
#add {
|
#add {
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
right: 44px;
|
right: 52px;
|
||||||
background-image: url(icons/add.svg);
|
background-image: url(icons/add.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,3 +393,13 @@ label[for="temperature"] {
|
|||||||
.completing #send {
|
.completing #send {
|
||||||
background-image: url(icons/stop.svg);
|
background-image: url(icons/stop.svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes rotating {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
477
static/chat.js
@@ -5,11 +5,286 @@
|
|||||||
$bottom = document.getElementById("bottom"),
|
$bottom = document.getElementById("bottom"),
|
||||||
$role = document.getElementById("role"),
|
$role = document.getElementById("role"),
|
||||||
$model = document.getElementById("model"),
|
$model = document.getElementById("model"),
|
||||||
|
$prompt = document.getElementById("prompt"),
|
||||||
$temperature = document.getElementById("temperature"),
|
$temperature = document.getElementById("temperature"),
|
||||||
$add = document.getElementById("add"),
|
$add = document.getElementById("add"),
|
||||||
$send = document.getElementById("send"),
|
$send = document.getElementById("send"),
|
||||||
$clear = document.getElementById("clear");
|
$clear = document.getElementById("clear");
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
|
||||||
|
function uid() {
|
||||||
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function make(tag, ...classes) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
|
||||||
|
el.classList.add(...classes);
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(markdown) {
|
||||||
|
return marked.parse(DOMPurify.sanitize(markdown), {
|
||||||
|
gfm: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scroll() {
|
||||||
|
$messages.scroll({
|
||||||
|
top: $messages.scrollHeight + 200,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Message {
|
||||||
|
#id;
|
||||||
|
#role;
|
||||||
|
#reasoning;
|
||||||
|
#text;
|
||||||
|
|
||||||
|
#editing = false;
|
||||||
|
#expanded = false;
|
||||||
|
#state = false;
|
||||||
|
|
||||||
|
#_message;
|
||||||
|
#_role;
|
||||||
|
#_reasoning;
|
||||||
|
#_text;
|
||||||
|
#_edit;
|
||||||
|
|
||||||
|
constructor(role, reasoning, text) {
|
||||||
|
this.#id = uid();
|
||||||
|
this.#role = role;
|
||||||
|
this.#reasoning = reasoning || "";
|
||||||
|
this.#text = text || "";
|
||||||
|
|
||||||
|
this.#build();
|
||||||
|
this.#render();
|
||||||
|
|
||||||
|
messages.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
#build() {
|
||||||
|
// main message div
|
||||||
|
this.#_message = make("div", "message", this.#role);
|
||||||
|
|
||||||
|
// message role
|
||||||
|
this.#_role = make("div", "role", this.#role);
|
||||||
|
|
||||||
|
this.#_message.appendChild(this.#_role);
|
||||||
|
|
||||||
|
// message reasoning (wrapper)
|
||||||
|
const _reasoning = make("div", "reasoning", "markdown");
|
||||||
|
|
||||||
|
this.#_message.appendChild(_reasoning);
|
||||||
|
|
||||||
|
// message reasoning (toggle)
|
||||||
|
const _toggle = make("button", "toggle");
|
||||||
|
|
||||||
|
_toggle.textContent = "Reasoning";
|
||||||
|
|
||||||
|
_reasoning.appendChild(_toggle);
|
||||||
|
|
||||||
|
_toggle.addEventListener("click", () => {
|
||||||
|
this.#expanded = !this.#expanded;
|
||||||
|
|
||||||
|
if (this.#expanded) {
|
||||||
|
this.#_message.classList.add("expanded");
|
||||||
|
} else {
|
||||||
|
this.#_message.classList.remove("expanded");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// message reasoning (content)
|
||||||
|
this.#_reasoning = make("div", "reasoning-text");
|
||||||
|
|
||||||
|
_reasoning.appendChild(this.#_reasoning);
|
||||||
|
|
||||||
|
// message content
|
||||||
|
this.#_text = make("div", "text", "markdown");
|
||||||
|
|
||||||
|
this.#_message.appendChild(this.#_text);
|
||||||
|
|
||||||
|
// message edit textarea
|
||||||
|
this.#_edit = make("textarea", "text");
|
||||||
|
|
||||||
|
this.#_message.appendChild(this.#_edit);
|
||||||
|
|
||||||
|
// message options
|
||||||
|
const _opts = make("div", "options");
|
||||||
|
|
||||||
|
this.#_message.appendChild(_opts);
|
||||||
|
|
||||||
|
// copy option
|
||||||
|
const _optCopy = make("button", "copy");
|
||||||
|
|
||||||
|
_optCopy.title = "Copy message content";
|
||||||
|
|
||||||
|
_opts.appendChild(_optCopy);
|
||||||
|
|
||||||
|
let timeout;
|
||||||
|
|
||||||
|
_optCopy.addEventListener("click", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(this.#text);
|
||||||
|
|
||||||
|
_optCopy.classList.add("copied");
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
_optCopy.classList.remove("copied");
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// edit option
|
||||||
|
const _optEdit = make("button", "edit");
|
||||||
|
|
||||||
|
_optEdit.title = "Edit message content";
|
||||||
|
|
||||||
|
_opts.appendChild(_optEdit);
|
||||||
|
|
||||||
|
_optEdit.addEventListener("click", () => {
|
||||||
|
this.toggleEdit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete option
|
||||||
|
const _optDelete = make("button", "delete");
|
||||||
|
|
||||||
|
_optDelete.title = "Delete message";
|
||||||
|
|
||||||
|
_opts.appendChild(_optDelete);
|
||||||
|
|
||||||
|
_optDelete.addEventListener("click", () => {
|
||||||
|
this.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$messages.appendChild(this.#_message);
|
||||||
|
|
||||||
|
scroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
#render(only = false) {
|
||||||
|
if (!only || only === "role") {
|
||||||
|
this.#_role.textContent = this.#role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!only || only === "reasoning") {
|
||||||
|
this.#_reasoning.innerHTML = render(this.#reasoning);
|
||||||
|
|
||||||
|
if (this.#reasoning) {
|
||||||
|
this.#_message.classList.add("has-reasoning");
|
||||||
|
} else {
|
||||||
|
this.#_message.classList.remove("has-reasoning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!only || only === "text") {
|
||||||
|
this.#_text.innerHTML = render(this.#text);
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
#save() {
|
||||||
|
const data = messages.map((message) => message.getData(true));
|
||||||
|
|
||||||
|
console.log("save", data);
|
||||||
|
|
||||||
|
localStorage.setItem("messages", JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
getData(includeReasoning = false) {
|
||||||
|
const data = {
|
||||||
|
role: this.#role,
|
||||||
|
text: this.#text,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.#reasoning && includeReasoning) {
|
||||||
|
data.reasoning = this.#reasoning;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(state) {
|
||||||
|
if (this.#state === state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#state) {
|
||||||
|
this.#_message.classList.remove(this.#state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
this.#_message.classList.add(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
addReasoning(chunk) {
|
||||||
|
this.#reasoning += chunk;
|
||||||
|
|
||||||
|
this.#render("reasoning");
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
|
addText(text) {
|
||||||
|
this.#text += text;
|
||||||
|
|
||||||
|
this.#render("text");
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopEdit() {
|
||||||
|
if (!this.#editing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toggleEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEdit() {
|
||||||
|
this.#editing = !this.#editing;
|
||||||
|
|
||||||
|
if (this.#editing) {
|
||||||
|
this.#_edit.value = this.#text;
|
||||||
|
|
||||||
|
this.#_edit.style.height = `${this.#_text.offsetHeight}px`;
|
||||||
|
this.#_edit.style.width = `${this.#_text.offsetWidth}px`;
|
||||||
|
|
||||||
|
this.setState("editing");
|
||||||
|
|
||||||
|
this.#_edit.focus();
|
||||||
|
} else {
|
||||||
|
this.#text = this.#_edit.value;
|
||||||
|
|
||||||
|
this.setState(false);
|
||||||
|
|
||||||
|
this.#render();
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete() {
|
||||||
|
const index = messages.findIndex((msg) => msg.#id === this.#id);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("delete", index);
|
||||||
|
|
||||||
|
messages.splice(index, 1);
|
||||||
|
|
||||||
|
this.#_message.remove();
|
||||||
|
|
||||||
|
this.#save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let controller;
|
let controller;
|
||||||
|
|
||||||
async function json(url) {
|
async function json(url) {
|
||||||
@@ -116,134 +391,21 @@
|
|||||||
function restore(models) {
|
function restore(models) {
|
||||||
$role.value = localStorage.getItem("role") || "user";
|
$role.value = localStorage.getItem("role") || "user";
|
||||||
$model.value = localStorage.getItem("model") || models[0].id;
|
$model.value = localStorage.getItem("model") || models[0].id;
|
||||||
|
$prompt.value = localStorage.getItem("prompt") || "normal";
|
||||||
$temperature.value = localStorage.getItem("temperature") || 0.85;
|
$temperature.value = localStorage.getItem("temperature") || 0.85;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages = JSON.parse(localStorage.getItem("messages") || "[]");
|
JSON.parse(localStorage.getItem("messages") || "[]").forEach(
|
||||||
|
(message) =>
|
||||||
messages.forEach(addMessage);
|
new Message(
|
||||||
|
message.role,
|
||||||
|
message.reasoning,
|
||||||
|
message.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMessages() {
|
|
||||||
localStorage.setItem("messages", JSON.stringify(buildMessages(false)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollMessages() {
|
|
||||||
$messages.scroll({
|
|
||||||
top: $messages.scrollHeight + 200,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEditing(el) {
|
|
||||||
const text = el.querySelector("div.text"),
|
|
||||||
edit = el.querySelector("textarea.text");
|
|
||||||
|
|
||||||
if (el.classList.contains("editing")) {
|
|
||||||
text.textContent = edit.value.trim();
|
|
||||||
|
|
||||||
el.classList.remove("editing");
|
|
||||||
|
|
||||||
saveMessages();
|
|
||||||
} else {
|
|
||||||
edit.value = text.textContent;
|
|
||||||
edit.style.height = `${text.offsetHeight}px`;
|
|
||||||
|
|
||||||
el.classList.add("editing");
|
|
||||||
|
|
||||||
edit.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addMessage(message) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
|
|
||||||
el.classList.add("message", message.role);
|
|
||||||
|
|
||||||
// message role
|
|
||||||
const role = document.createElement("div");
|
|
||||||
|
|
||||||
role.textContent = message.role;
|
|
||||||
role.classList.add("role");
|
|
||||||
|
|
||||||
el.appendChild(role);
|
|
||||||
|
|
||||||
// message content
|
|
||||||
const text = document.createElement("div");
|
|
||||||
|
|
||||||
text.textContent = message.content;
|
|
||||||
text.classList.add("text");
|
|
||||||
|
|
||||||
el.appendChild(text);
|
|
||||||
|
|
||||||
// message edit textarea
|
|
||||||
const edit = document.createElement("textarea");
|
|
||||||
|
|
||||||
edit.classList.add("text");
|
|
||||||
|
|
||||||
el.appendChild(edit);
|
|
||||||
|
|
||||||
// message options
|
|
||||||
const opts = document.createElement("div");
|
|
||||||
|
|
||||||
opts.classList.add("options");
|
|
||||||
|
|
||||||
el.appendChild(opts);
|
|
||||||
|
|
||||||
// edit option
|
|
||||||
const optEdit = document.createElement("button");
|
|
||||||
|
|
||||||
optEdit.title = "Edit message content";
|
|
||||||
optEdit.classList.add("edit");
|
|
||||||
|
|
||||||
opts.appendChild(optEdit);
|
|
||||||
|
|
||||||
optEdit.addEventListener("click", () => {
|
|
||||||
toggleEditing(el);
|
|
||||||
});
|
|
||||||
|
|
||||||
// delete option
|
|
||||||
const optDelete = document.createElement("button");
|
|
||||||
|
|
||||||
optDelete.title = "Delete message";
|
|
||||||
optDelete.classList.add("delete");
|
|
||||||
|
|
||||||
opts.appendChild(optDelete);
|
|
||||||
|
|
||||||
optDelete.addEventListener("click", () => {
|
|
||||||
el.remove();
|
|
||||||
|
|
||||||
saveMessages();
|
|
||||||
});
|
|
||||||
|
|
||||||
// append to messages
|
|
||||||
$messages.appendChild(el);
|
|
||||||
|
|
||||||
scrollMessages();
|
|
||||||
|
|
||||||
return {
|
|
||||||
set(content) {
|
|
||||||
text.textContent = content;
|
|
||||||
|
|
||||||
scrollMessages();
|
|
||||||
},
|
|
||||||
state(state) {
|
|
||||||
if (state && el.classList.contains(state)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.classList.remove("waiting", "reasoning", "receiving");
|
|
||||||
|
|
||||||
if (state) {
|
|
||||||
el.classList.add(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollMessages();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushMessage() {
|
function pushMessage() {
|
||||||
const text = $message.value.trim();
|
const text = $message.value.trim();
|
||||||
|
|
||||||
@@ -251,40 +413,9 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
addMessage({
|
|
||||||
role: $role.value,
|
|
||||||
content: text,
|
|
||||||
});
|
|
||||||
|
|
||||||
$message.value = "";
|
$message.value = "";
|
||||||
|
|
||||||
saveMessages();
|
return new Message($role.value, "", text);
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMessages(clean = true) {
|
|
||||||
const messages = [];
|
|
||||||
|
|
||||||
$messages.querySelectorAll(".message").forEach((message) => {
|
|
||||||
if (clean && message.classList.contains("editing")) {
|
|
||||||
toggleEditing(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = message.querySelector(".role"),
|
|
||||||
text = message.querySelector(".text");
|
|
||||||
|
|
||||||
if (!role || !text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({
|
|
||||||
role: role.textContent.trim(),
|
|
||||||
content: text.textContent.trim().replace(/\r/g, ""),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$messages.addEventListener("scroll", () => {
|
$messages.addEventListener("scroll", () => {
|
||||||
@@ -313,6 +444,10 @@
|
|||||||
localStorage.setItem("model", $model.value);
|
localStorage.setItem("model", $model.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$prompt.addEventListener("change", () => {
|
||||||
|
localStorage.setItem("prompt", $prompt.value);
|
||||||
|
});
|
||||||
|
|
||||||
$temperature.addEventListener("input", () => {
|
$temperature.addEventListener("input", () => {
|
||||||
localStorage.setItem("temperature", $temperature.value);
|
localStorage.setItem("temperature", $temperature.value);
|
||||||
});
|
});
|
||||||
@@ -330,9 +465,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$messages.innerHTML = "";
|
for (const message of messages) {
|
||||||
|
message.delete();
|
||||||
saveMessages();
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$send.addEventListener("click", () => {
|
$send.addEventListener("click", () => {
|
||||||
@@ -349,26 +484,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
pushMessage();
|
pushMessage();
|
||||||
saveMessages();
|
|
||||||
|
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
|
|
||||||
$chat.classList.add("completing");
|
$chat.classList.add("completing");
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
|
prompt: $prompt.value,
|
||||||
model: $model.value,
|
model: $model.value,
|
||||||
temperature: temperature,
|
temperature: temperature,
|
||||||
messages: buildMessages(),
|
messages: messages.map((message) => message.getData()),
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = {
|
const message = new Message("assistant", "", "");
|
||||||
role: "assistant",
|
|
||||||
content: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const message = addMessage(result);
|
message.setState("waiting");
|
||||||
|
|
||||||
message.state("waiting");
|
|
||||||
|
|
||||||
stream(
|
stream(
|
||||||
"/-/chat",
|
"/-/chat",
|
||||||
@@ -384,28 +514,27 @@
|
|||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
controller = null;
|
controller = null;
|
||||||
|
|
||||||
saveMessages();
|
message.setState(false);
|
||||||
|
|
||||||
$chat.classList.remove("completing");
|
$chat.classList.remove("completing");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(chunk);
|
||||||
|
|
||||||
switch (chunk.type) {
|
switch (chunk.type) {
|
||||||
case "reason":
|
case "reason":
|
||||||
message.state("reasoning");
|
message.setState("reasoning");
|
||||||
|
message.addReasoning(chunk.text);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "text":
|
case "text":
|
||||||
result.content += chunk.text;
|
message.setState("receiving");
|
||||||
|
message.addText(chunk.text);
|
||||||
message.state("receive");
|
|
||||||
message.set(result.content);
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveMessages();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
BIN
static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
7
static/icons/check.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 579 B |
7
static/icons/chevron.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 568 B |
7
static/icons/copy.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 783 B |
7
static/icons/loading.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 1.1 KiB |
@@ -3,5 +3,5 @@
|
|||||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 894 B |
7
static/icons/prompt.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 685 B |
7
static/icons/reasoning.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||||
|
|
||||||
|
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
|
After Width: | Height: | Size: 1.2 KiB |
@@ -36,6 +36,13 @@
|
|||||||
<label for="model" title="Model"></label>
|
<label for="model" title="Model"></label>
|
||||||
<select id="model"></select>
|
<select id="model"></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="option">
|
||||||
|
<label for="prompt" title="Prompt"></label>
|
||||||
|
<select id="prompt">
|
||||||
|
<option value="" selected>No Prompt</option>
|
||||||
|
<option value="normal">Assistant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="option">
|
<div class="option">
|
||||||
<label for="temperature" title="Temperature (0 - 1)"></label>
|
<label for="temperature" title="Temperature (0 - 1)"></label>
|
||||||
<input id="temperature" type="number" min="0" max="1" step="0.05" value="0.85" />
|
<input id="temperature" type="number" min="0" max="1" step="0.05" value="0.85" />
|
||||||
@@ -47,6 +54,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="purify.min.js"></script>
|
||||||
|
<script src="marked.min.js"></script>
|
||||||
<script src="chat.js"></script>
|
<script src="chat.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
BIN
static/logo.png
Normal file
After Width: | Height: | Size: 84 KiB |