ai-ui/vite/src/ChatApp.svelte

524 lines
15 KiB
Svelte

<script>
import markdownit from 'markdown-it'
import hljs from 'highlight.js';
import ChatMessage from './ChatMessage.svelte';
import Modal from './Modal.svelte';
import AssistantForm from './AssistantForm.svelte';
import { onMount } from 'svelte';
export let proxyBaseUrl = '';
export let model_list = [];
let assistant = null;
let assistant_id = 0;
let assistant_title = '';
let assistants = [];
let messages = [];
let chatInput; // Reference to the chat input textarea
let chatContainer;
let assistantForm; // Reference to the AssistantForm component
let modal; // Reference to the Modal component
onMount(() => {
refreshAssistants();
});
const md = markdownit({
linkify: true,
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
const html = hljs.highlight(code, {language: language, ignoreIllegals: true }).value;
return '<pre class="hljs-code-container my-3">'
+ '<div class="hljs-code-header bg-light-subtle text-light-emphasis">' + `<span>${language}</span>`
+ '<button class="hljs-copy-button btn btn-secondary btn-sm" title="Copier le code">'
+ '<span class="icon-content_copy"></span></button></div>'
+ `<code class="hljs language-${language}">${html}</code>`
+ '</pre>';
},
});
/*
*/
async function postRequest(url, headers, body) {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(await response.text());
}
return response;
}
async function readStream(stream, progressCallback) {
const reader = stream.getReader();
const textDecoder = new TextDecoder('utf-8');
let responseObj = {};
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = textDecoder.decode(value).split("\n");
processLines(lines, responseObj, progressCallback);
}
return responseObj;
}
function processLines(lines, responseObj, progressCallback) {
for (const line of lines) {
if (line.startsWith("data: ")) {
if (line.includes("[DONE]")) {
return responseObj;
}
try {
const data = JSON.parse(line.slice(6));
const delta = data.choices[0].delta;
Object.keys(delta).forEach(key => {
responseObj[key] = (responseObj[key] || "") + delta[key];
progressCallback(responseObj);
});
} catch (e) {
console.log("Error parsing line:", line);
}
}
}
}
async function complete(messages, apiUrl, params, progressCallback) {
const headers = {
"Content-Type": "application/json",
};
const body = {
model: params.model,
messages: messages,
stream: true,
};
if (params.temperature != undefined) {
body.temperature = params.temperature;
}
if (params.top_p != undefined) {
body.top_p = params.top_p;
}
const response = await postRequest(apiUrl, headers, body);
return readStream(response.body, progressCallback);
}
function sendMessage(newMessage) {
if (newMessage.trim() === '') return;
if (!messages.length && assistant) {
const systemMessage = { role: 'system', content: assistant.system_prompt };
messages.push(systemMessage);
}
const userMessage = { role: 'user', content: newMessage };
messages.push(userMessage);
messages.push({ role: 'assistant', content: '' });
messages = messages;
const lastMsgIndex = messages.length - 1;
try {
if (assistant) {
complete(
messages,
proxyBaseUrl,
// apiKey,
assistant,
(message) => {
if (message.content)
messages[lastMsgIndex].content = message.content;
}
);
} else if (!assistants.length) {
messages[lastMsgIndex].content = "Aucun assistant n'a été trouvé. Veuillez en créer un nouveau en cliquant sur le bouton +.";
}
} catch (error) {
console.log(error.message);
return;
} finally {
newMessage = '';
}
}
function handleChatInputKey(event) {
if (event.key === 'Enter' && event.shiftKey) {
// Allow the default behavior of adding a new line
return;
}
if (event.key === 'Enter') {
sendMessage(chatInput.value);
}
}
function clearMessages() {
messages = [];
}
function loadAssistant(config = {}) {
assistant = null;
// ES6 Destructuring object properties into variables with default values
const {
model = '',
system_prompt = '',
temperature = 0,
top_p = 0
} = config;
// ES6 (Property Shorthand)
assistant = {model, system_prompt, temperature, top_p };
assistant_id = config.id;
assistant_title = config.title;
document.title = assistant_title;
// console.log("Changed assistant " + assistant_id);
// console.log(assistant);
}
async function refreshAssistants() {
try {
const response = await fetch('/site/assistant/list', {method: 'GET'});
if (!response.ok) {
throw new Error('Failed to fetch assistants');
}
assistants = await response.json();
if (assistants.length) {
assistants.forEach(config => {
if (config.default == 1) {
loadAssistant(config);
return;
}
});
if (!assistant) {
loadAssistant(assistants[0]);
}
}
} catch (error) {
console.error('Error refreshing assistants:', error);
}
}
// Handle saving assistant data
async function handleSave(event) {
try {
const assistantData = event.detail;
const response = await fetch('/site/assistant/save', {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(assistantData),
});
if (!response.ok) {
throw new Error('Server response error');
}
const data = await response.json();
if (data.status == 'success') {
refreshAssistants();
modal.hide();
} else if (data.status == 'error' && data.errors) {
assistantForm.setErrors(data.errors);
}
} catch (error) {
console.error('Error saving assistant:', error);
}
}
// Handle changing assistant
async function handleChange() {
try {
assistants.forEach(config => {
if (config.id == assistant_id) {
loadAssistant(config);
return;
}
});
const response = await fetch('/site/assistant/set-as-default?id=' + assistant_id);
if (!response.ok) {
throw new Error('Server response error');
}
const status = await response.json();
if (!status) {
throw new Error('Failed to set default assistant');
}
} catch (error) {
console.error('Error changing assistant:', error);
}
}
function createAssistant()
{
assistantForm.setAssistant({
title: '',
model: 'gpt-4o',
system_prompt: '',
temperature: 0,
top_p: 1.0,
default: 1
});
modal.show();
}
function editAssistant()
{
assistants.forEach(config => {
if (config.id == assistant_id) {
assistantForm.setAssistant(config);
modal.show();
return;
}
});
}
async function deleteAssistant()
{
if (assistant && confirm(`Êtes-vous certain de vouloir effacer l'assistant ${assistant_title} ?`)) {
try {
const response = await fetch('/site/assistant/delete?id=' + assistant_id);
if (!response.ok) {
throw new Error('Server response error');
}
const status = await response.json();
if (!status) {
throw new Error('Failed to delete assistant');
}
assistant = null;
refreshAssistants();
} catch (error) {
console.error('Error deleting assistant:', error);
}
}
}
function exportMessages() {
const date = new Date();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const formattedDate = `${day}-${month}-${date.getFullYear()}`;
const formattedTime = `${hours}-${minutes}-${seconds}`;
const filename = `${assistant_title}_${formattedDate}_${formattedTime}.json`;
const json = JSON.stringify(messages, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// Fonction pour importer les messages depuis un fichier JSON
function importMessages(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedMessages = JSON.parse(e.target.result);
if (Array.isArray(importedMessages)) {
messages = importedMessages;
} else {
throw new Error("Invalid JSON format");
}
} catch (error) {
console.error("Error importing messages:", error);
}
};
reader.readAsText(file);
}
function handleEditMessage(event) {
const { index, content } = event.detail;
messages[index].content = content;
messages = messages.slice(0, index);
sendMessage(content);
}
</script>
<header>
<h1 class="text-center fw-lighter">{assistant_title}</h1>
<div class="toolbar">
<button class="btn btn-primary" on:click={createAssistant} title="Créer un assistant"><span class="icon-add"></span></button>
{#if assistants.length}
<select class="form-select" bind:value={assistant_id} on:change={handleChange}>
{#each assistants as config}
<option value={config.id}>{config.title}</option>
{/each}
</select>
{#if assistant}
<button class="btn btn-secondary" on:click={editAssistant} title="Modifier l'assistant {assistant_title}"><span class="icon-edit"></span></button>
<button class="btn btn-danger" on:click={deleteAssistant} title="Supprimer l'assistant {assistant_title}"><span class="icon-delete"></span></button>
{/if}
{/if}
{#if messages.length}
<div class="separator d-none d-sm-block"></div>
<button class="btn btn-warning clear" on:click="{clearMessages}" title="Effacer les messages"><span class="icon-clean"></button>
<button class="btn btn-info" on:click="{exportMessages}" title="Exporter la conversation"><span class="icon-file_download"></span></button>
<div class="separator d-none d-sm-block"></div>
{/if}
<input id="import-file" type="file" accept=".json" on:change="{importMessages}" style="display:none" />
<label for="import-file" class="btn btn-info"><span class="icon-file_upload" title="Importer une conversation"></span></label>
</div>
</header>
<main>
<div class="chat-container" bind:this="{chatContainer}" >
{#each messages as message, index (index)}
<ChatMessage message="{message}"
message_index={index} on:editMessage="{handleEditMessage}"
container="{chatContainer.parentElement}" markdown={md} />
{/each}
</div>
</main>
<footer>
<textarea class="chat-input" rows="1"
bind:this="{chatInput}"
on:keyup={handleChatInputKey}
placeholder="Saisissez votre message ici..."
aria-label="Chat with AI"></textarea>
</footer>
<Modal bind:this="{modal}" ariaLabelledby="editAssistantModal">
<AssistantForm models={model_list} bind:this={assistantForm} on:save={handleSave} />
</Modal>
<style lang="scss">
header {
background-color: #333;
color: white;
padding: 8px;
}
.toolbar {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
.separator {
background-color: #4a4a4a;
width: 1px;
margin: 0 15px;
}
& > select {
width: auto;
}
/*
& > button {
}
*/
}
main {
flex-grow: 1;
overflow-y: auto;
}
button.clear {
right: 0;
}
footer {
width: 100%;
padding: 8px;
display: flex;
justify-content: center;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.chat-input {
width: 100%;
max-width: 768px;
padding: 8px;
border-width: 1px;
border-color: rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
min-height: 40px;
}
.chat-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 768px;
margin: 0 auto;
overflow-wrap: break-word;
}
</style>