524 lines
15 KiB
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>
|
|
|