ai.chat/chat.html

296 lines
14 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="contrib/lnstyles/css/lnstyles.css">
<link rel="stylesheet" href="contrib/highlight/default.min.css">
<link rel="stylesheet" href="chat.css">
<link rel="stylesheet" href="code.css">
<link rel="icon" type="image/svg+xml" href="/contrib/lnstyles/svg/ln.svg">
<script type="application/javascript" src="./contrib/highlight/highlight.min.js"></script>
<title>ai.chat - local ai test chat</title>
</head>
<body class="bg-white p-2 fill">
<div id="app" class="display-flex flex-row" v-cloak>
<div v-if="!chatApp.identity.authenticated" class="flex-grow-1">
<div class="row p-4">
<div class="sz-12 vh-20"></div>
<div class="sz-12">
<div class="m-auto" style="width: min-content;">
<div class="text-center mb-2 border-bottom">
<h1>AI.chat</h1>
</div>
<div class="">Benutzername</div>
<div class="p-1 text-center"><input type="text" v-model="loginUsername"></div>
<div class="">Passwort</div>
<div class="p-1 text-center"><input type="password" v-model="loginPassword"></div>
<div class="pt-2 text-center">
<button @click="authenticate">Anmelden...</button>
</div>
</div>
</div>
</div>
</div>
<template v-if="chatApp.identity.authenticated">
<div class="flex-grow-0 display-flex flex-column" style="min-width: 400px;">
<h1 class="pl-1 flex-grow-0">AI.chat</h1>
<div class="flex-grow-0">
<v-svg
class="large hover"
tooltip="neu..."
src="contrib/lnstyles/svg/neutral/newdocument.svg"
@click="newChat();"></v-svg>
<v-svg
class="large hover"
tooltip="import..."
src="contrib/lnstyles/svg/neutral/import.svg"
@click="importChats();"></v-svg>
<v-svg
class="large hover"
tooltip="export..."
src="contrib/lnstyles/svg/neutral/export.svg"
@click="exportChats();"></v-svg>
<select v-model="chatApp.defaultModel"
@change="chatApp.saveChats();">
<option v-for="model in chatApp.models"
:value="model">{{ model }}</option>
</select>
</div>
<div class="flex-grow-1 overflow-scroll">
<div v-for="chat in chatApp.chats.slice().reverse()"
class="border-radius-1 border-1 row m-1 p-1"
:class="{ 'bg-heading': (chatApp.currentChat == chat), 'bg-lightblue': (chat.waiting) }"
@click="setCurrentChat(chat);"
>
<div class="sz-11 row">
<small>{{ new Date(chat.created).toLocaleString() }}</small>
<small><i>{{ chat.model }}</i></small>
<small v-if="chat.assistant"
class="flex-grow-1 text-right">{{ chat.assistant.name }}</small>
</div>
<div class="sz-1">
<img src="contrib/lnstyles/svg/triangle-down.svg"
class="position-absolute" style="width: 1em; right: 2em; top: 1em;"
@click="exportChat(chat);">
<img src="contrib/lnstyles/svg/delete.svg"
class="position-absolute" style="width: 1em; right: 1em; top: 1em;"
@click="chatApp.removeChat(chat);">
</div>
<div class="sz-12 text-overflow-ellipsis smaller">{{ chat.title }}</div>
</div>
</div>
</div>
<div class="flex-grow-1 display-flex flex-column">
<div class="sz-9 bg-white border-radius-2 display-flex flex-column h-100 position-relative">
<div class="bg-white border-radius-2 flex-grow-1 overflow-scroll smaller px-2 pt-2"
style="max-height: 100%; padding-bottom: 10em;">
<div id="chatMessages">
<template v-for="chatMessage in chatApp.currentChat.messages">
<div
v-if="chatMessage.role !== 'system'"
class="border-radius-2 p-2 mb-2 position-relative"
:class="{
'bg-lightgreen': (chatMessage.role === 'user'),
'ml-4': (chatMessage.role === 'user') || (chatMessage.role === 'file'),
'bg-lightgray': (chatMessage.role === 'file'),
'bg-assistant': (chatMessage.role === 'assistant'),
'mr-4': (chatMessage.role === 'assistant')
}"
>
<v-svg
class="position-absolute hover" style="right: 0.5em; top: 0.5em;"
src="contrib/lnstyles/svg/neutral/copy.svg"
@click="copyToClipboard($event);"
></v-svg>
<v-svg
v-if="chatMessage.role === 'file'"
class="position-absolute hover" style="right: 2.5em; top: 0.5em;"
:src="chatMessage.expanded ? 'contrib/lnstyles/svg/neutral/dropdown-close.svg' : 'contrib/lnstyles/svg/neutral/dropdown-open.svg'"
@click="chatMessage.expanded = !chatMessage.expanded"
></v-svg>
<v-message v-if="chatMessage.role !== 'file'"
:format="convertMarkdown"
:content="chatMessage.content"
></v-message>
<div v-if="chatMessage.role === 'file'">
<div>Datei <b>{{ chatMessage.name }}</b> ({{ chatMessage.content.length }} Bytes)
</div>
<div v-if="chatMessage.expanded"
class="pre">
<code class="hljs" v-html="highlightSyntax(chatMessage.content)"></code>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="p-1 position-absolute m-1 border border-radius-2"
style="left: 0; right: 0; bottom: 0; z-index: 100; background-color: rgba(255,255,255,0.9);">
<div class="row">
<div class="sz-12 small">
<select v-model="chatApp.currentChat.model">
<option v-for="model in chatApp.models"
:value="model">{{ model }}</option>
</select>
<select v-model="chatApp.currentChat.assistant">
<option :value="null">-</option>
<option v-for="assistant in assistants"
:value="assistant"
>{{ assistant.name }}
</option>
</select>
<button @click="attachFile();">Dateien einfügen...</button>
<span v-if="chatApp.currentChat.waiting">warte auf Antwort...</span>
<button v-if="chatApp.currentChat.chatting && !chatApp.currentChat.waiting"
@click="chatApp.currentChat.rebuildChat();">nochmal versuchen...
</button>
<button v-if="!chatApp.currentChat.chatting && !chatApp.currentChat.waiting && !chatApp.currentChat.empty"
@click="chatApp.currentChat.rebuildChat();">wiederholen...
</button>
</div>
<div :contenteditable="!chatApp.currentChat.chatting || undefined"
class="sz-12 border-radius-1 p-1 smaller overflow-auto"
id="prompt"
style="min-height: 3em; max-height: 8em; border: 1px solid lightgray;"
@input="promptChanged" @keypress="promptKeypress"
></div>
</div>
</div>
</div>
</div>
</template>
</div>
<script type="application/javascript" src="markdown-it.min.js"></script>
<script type="module">
import {createApp, reactive, nextTick} from "./contrib/lnstyles/js/vue.esm-browser.js";
import {ChatApp} from "./chat.js";
import {LNS} from "./contrib/lnstyles/js/lnstyles.js";
import {vsvg} from "./vsvg.js";
import {vmessage} from "./v-message.js";
var $ = LNS;
let config = await fetch("config.json").then((r) => r.json());
let markdown = markdownit();
markdown.use((md) => {
const temp = md.renderer.rules.fence.bind(md.renderer.rules)
md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const token = tokens[idx]
const code = token.content.trim()
if (token.info.length > 0) {
return `<pre><code class="hljs">${hljs.highlightAuto(code, [token.info]).value}</code></pre>`
}
return temp(tokens, idx, options, env, slf)
}
});
var defaultRender = markdown.renderer.rules.link_open || function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
markdown.renderer.rules.link_open = function (tokens, idx, options, env, self) {
// Add a new `target` attribute, or replace the value of the existing one.
tokens[idx].attrSet('target', '_blank');
// Pass the token to the default renderer.
return defaultRender(tokens, idx, options, env, self);
};
var app = createApp({
data() {
let chatApp = new ChatApp();
reactive(chatApp).loginIdentity.restoreToken();
return {
chatApp: chatApp,
currentMessageText: "",
assistants: config.assistants,
loginUsername: "",
loginPassword: "",
$,
};
},
methods: {
promptKeypress(evt) {
if ((evt.which === 13) && !evt.shiftKey) {
evt.preventDefault();
this.chatApp.chat(evt.target.innerText);
evt.target.innerText = "";
}
},
promptChanged(evt){
this.currentMessageText = evt.target.innerText;
},
convertMarkdown(md) {
let mdHtml = markdown.render(md);
return mdHtml;
},
highlightSyntax(src) {
return hljs.highlightAuto(src).value;
},
newChat() {
this.chatApp.newChat();
},
copyToClipboard(evt) {
let e = evt.target.parentElement.parentElement;
navigator.clipboard.writeText(e.innerText)
.then(
() => {
console.log("copy succeded");
},
() => {
console.log("copy failed");
}
)
},
authenticate() {
console.log("authenticate", this.loginUsername);
this.chatApp.identity.authenticate(this.loginUsername, this.loginPassword);
},
exportChat(chat) {
$.saveAs(chat.title + '.json', chat.backup());
},
exportChats() {
$.saveAs('ai.chats.json', this.chatApp.backup());
},
importChats() {
$.openFile((filename, content) => {
let jsonImport = JSON.parse(content);
if (!jsonImport.exported) {
alert("Datei ist kein ai.chat export");
} else if (jsonImport.chats) {
jsonImport.chats.forEach((jsonChat) => {
this.chatApp.newChat(jsonChat);
});
} else if (jsonImport.messages) {
this.chatApp.newChat(jsonImport);
}
}, [".json"])
},
attachFile() {
$.openFile((files) => {
files.forEach((file) => {
this.chatApp.currentChat.pushFile(file.filename, file.content);
});
}, {multiple: true,});
},
setCurrentChat(chat) {
this.chatApp.currentChat = chat;
nextTick(() => {
document.querySelector("#chatMessages > div:last-of-type").scrollIntoView({behavior: "instant"});
})
},
}
})
.component("v-svg", vsvg)
.component("v-message", vmessage)
.mount("#app");
</script>
</body>
</html>