diff --git a/index.html b/index.html index 1af303e..a939b7c 100644 --- a/index.html +++ b/index.html @@ -5,314 +5,306 @@ gpt-spa - Scalable Dynamics - + -
- - - - - - - - +
+ + + +
+

+
+
+ + +
+
+
+
+ + + +
+
+
+
+
+ + +
+
+ + + + + + + + + + + + + +
+ + + +
+
+
+
+
+
-
diff --git a/js/cards.js b/js/cards.js new file mode 100644 index 0000000..1136795 --- /dev/null +++ b/js/cards.js @@ -0,0 +1,119 @@ +export function contentCard(content) { + const card = { + type: 'AdaptiveCard', + version: '1.5', + body: [{ + type: 'TextBlock', + text: content, + wrap: true + }] + } + const adaptiveCard = new AdaptiveCards.AdaptiveCard(); + adaptiveCard.parse(card); + return adaptiveCard.render(); +} + +export function confirmationCard(message) { + const card = { + type: 'AdaptiveCard', + version: '1.5', + body: [{ + type: 'TextBlock', + text: message, + wrap: true + }], + actions: [{ + type: 'Action.Submit', + title: 'Yes', + data: 'yes' + }, { + type: 'Action.Submit', + title: 'No', + data: 'no' + }] + } + const adaptiveCard = new AdaptiveCards.AdaptiveCard(); + adaptiveCard.parse(card); + const renderedCard = adaptiveCard.render(); + return { + render: () => renderedCard, + getAnswer: () => new Promise((resolve) => { + adaptiveCard.onExecuteAction = (action) => { + renderedCard.parentNode.removeChild(renderedCard) + resolve(action.data === 'yes') + } + }) + } +} + +export function questionCard(question, answers = [], defaultValue = "") { + const card = { + type: 'AdaptiveCard', + version: '1.5', + body: [{ + type: 'TextBlock', + text: question, + wrap: true + }], + actions: answers.filter(answer => answer && answer.trim()).map(answer => ({ + type: 'Action.Submit', + title: answer, + data: answer + })) + } + if (card.actions.length == 0) { + card.body = [{ + type: "Input.Text", + id: "answer", + label: question, + value: defaultValue, + isMultiline: question.indexOf('prompt') > -1 + }]; + card.actions.push({ + type: 'Action.Submit', + title: 'Save' + }); + } + const adaptiveCard = new AdaptiveCards.AdaptiveCard(); + adaptiveCard.parse(card); + const renderedCard = adaptiveCard.render(); + return { + render: () => renderedCard, + getAnswer: () => new Promise((resolve) => { + adaptiveCard.onExecuteAction = (action) => { + renderedCard.parentNode.removeChild(renderedCard) + if (action.data === '_cancel_' || (typeof action.data === 'object' && !action.data.answer)) { + resolve() + } else if (typeof action.data === 'object') { + resolve(action.data.answer) + } else { + resolve(action.data) + } + } + }) + } +} + +export async function configCard(outputContainer, option, text = `Enter the value for ${option}:`, defaultValue = undefined) { + const value = getQueryString(option) || localStorage.getItem(option) + if (value) return value + const question = questionCard(text, undefined, defaultValue) + outputContainer.appendChild(question.render()) + const answer = await question.getAnswer() + if (answer) { + localStorage.setItem(option, answer) + return answer + } +} + +export function useCardMarkdownRenderer(render) { + AdaptiveCards.AdaptiveCard.onProcessMarkdown = function (text, result) { + result.outputHtml = render(text); + result.didProcess = true; + }; +} + +function getQueryString(key) { + var urlParams = new URLSearchParams(window.location.search) + return urlParams.get(key) +} \ No newline at end of file diff --git a/js/chat.js b/js/chat.js new file mode 100644 index 0000000..ef65fe4 --- /dev/null +++ b/js/chat.js @@ -0,0 +1,24 @@ +export function createConversation(systemPrompt, getCompletion, getImage, vectorSearch) { + const messages = [{ role: "system", content: systemPrompt }]; + return async (content, onTextReceived = undefined, useVectorSearch = false, generateImage = false) => { + const useVision = Array.isArray(content) + if (useVectorSearch) { + const results = await vectorSearch(useVision ? content[0].text : content); + if (results.length > 0) { + const retrieval = results[0].text; + messages.push({ content: `More details: ${retrieval}`, role: 'user' }); + } + } + messages.push({ content, role: 'user' }); + if (generateImage) { + messages.push({ content: 'Create a prompt for DALL-E to generate an image. Only return the prompt, NO OTHER TEXT.', role: 'user' }); + } + const response = await getCompletion(messages, onTextReceived, useVision); + messages.push({ content: response, role: 'assistant' }); + if (generateImage) { + return await getImage(response); + } else { + return response; + } + } +} \ No newline at end of file diff --git a/js/files.js b/js/files.js new file mode 100644 index 0000000..b9612cf --- /dev/null +++ b/js/files.js @@ -0,0 +1,93 @@ +import * as pdfjsLib from 'https://mozilla.github.io/pdf.js/build/pdf.mjs'; +pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://mozilla.github.io/pdf.js/build/pdf.worker.mjs'; + +export function onFilesDropped(element, onFilesAdded) { + element.addEventListener('dragover', prevent_defaults, false); + element.addEventListener('dragenter', prevent_defaults, false); + element.addEventListener('drop', async (e) => { + prevent_defaults(e); + addFiles(e.dataTransfer.files); + }, false); + return async function addFiles(files) { + const contents = []; + for (let i = 0; i < files.length; i++) { + const name = files[i].name; + const type = files[i].type; + try { + if (type === 'application/pdf') { + contents.push({ + name, + type, + content: await read_pdf(files[i]) + }); + } else if (type.indexOf('image') === 0) { + contents.push({ + name, + type, + content: await read_image(files[i]) + }) + } else { + contents.push({ + name, + type, + content: await read_file(files[i]) + }); + } + } catch (e) { + console.error('FileReader error: ', e); + alert(`Error reading file ${name}`) + } + } + onFilesAdded(contents); + } +} + +function read_file(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + resolve(event.target.result); + }; + reader.onerror = reject; + reader.readAsText(file); + }); +} + +function read_image(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = function () { + resolve(reader.result); + } + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function read_pdf(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = async (event) => { + try { + const pdfData = new Uint8Array(reader.result); + const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise; + let content = ""; + for (let i = 1; i <= pdfDoc.numPages; i++) { + const page = await pdfDoc.getPage(i); + const textContent = await page.getTextContent(); + content += textContent.items.map(item => item.str).join(" "); + } + resolve(content); + } catch (error) { + reject(error); + } + }; + reader.onerror = reject; + reader.readAsArrayBuffer(file); + }); +} + +function prevent_defaults(e) { + e.preventDefault(); + e.stopPropagation(); +} \ No newline at end of file diff --git a/js/openai.js b/js/openai.js new file mode 100644 index 0000000..5f6f8f8 --- /dev/null +++ b/js/openai.js @@ -0,0 +1,173 @@ +export default function (apiKey, apiOrg) { + const headers = { + 'Authorization': `Bearer ${apiKey}`, + 'OpenAI-Organization': apiOrg + } + return { + getCompletion: get_completion.bind(null, headers), + getImage: get_image.bind(null, headers), + playSpeech: play_speech.bind(null, headers), + getTranscription: get_transcription.bind(null, headers), + getEmbeddings: get_vectors.bind(null, headers) + } +} + +async function get_completion(headers, messages, onTextReceived = undefined, useVision = false, max_tokens = 150) { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + headers: { + ...headers, + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify({ + //TODO: not working with successive requests (since image content gets added to the messages), but acording to sama its the 'same' + //model: useVision ? 'gpt-4-vision-preview' : 'gpt-4-1106-preview', + model: 'gpt-4-vision-preview', + messages, + max_tokens, + temperature: 0.1, + top_p: 1, + stream: (onTextReceived !== undefined) + }) + }) + if (onTextReceived) { + if (!response.ok) throw new Error(await response.text()); + if (!response.body[Symbol.asyncIterator]) { + response.body[Symbol.asyncIterator] = () => { + const reader = response.body.getReader(); + return { + next: () => reader.read(), + }; + }; + } + let all_text = "" + const decoder = new TextDecoder() + for await (const chunk_arr of response.body) { + const chunk = decoder.decode(chunk_arr, { stream: true }) + const lines = chunk.toString().trim().split("\n") + for (let line of lines) { + const data = line.indexOf('{') + if (data > -1) { + const json = line.slice(data); + if (json) { + try { + const data = JSON.parse(json); + if (data.choices && data.choices.length > 0) { + if (data.choices[0].finish_reason === "length" && max_tokens < 1000) { + onTextReceived("", true) + return await get_completion(headers, onTextReceived, useVision, max_tokens * 2) + } + const text = data.choices[0].delta.content; + if (!text) continue; + all_text += text; + onTextReceived(text); + } + } catch (e) { + console.error(e) + //TODO: handle this better + } + } + } + } + } + return all_text + } else { + const data = await response.json() + if (data && data.choices && data.choices[0] && data.choices[0].finish_reason === "length" && max_tokens < 1000) { + return await get_completion(headers, onTextReceived, useVision, max_tokens * 2) + } + else if (data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) { + return data.choices[0].message.content.trim() + } else { + return "" + } + } +} + +async function get_image(headers, prompt) { + const response = await fetch("https://api.openai.com/v1/images/generations", { + headers: { + ...headers, + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify({ + prompt, + model: 'dall-e-3', + size: '1024x1024', + n: 1 + }) + }) + const image = await response.json() + if (image && image.data && image.data[0] && image.data[0].url) { + return image.data[0].url + } +} + +async function play_speech(headers, input, voice = 'alloy') { + const response = await fetch("https://api.openai.com/v1/audio/speech", { + headers: { + ...headers, + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify({ + input, + voice, + model: 'tts-1' + }) + }) + const audioData = []; + const reader = response.body.getReader(); + const audioObj = new Audio(); + + reader.read().then(function processAudio({ done, value }) { + if (done) { + audioObj.src = URL.createObjectURL(new Blob(audioData)); + audioObj.play(); + document.body.onclick = () => { + audioObj.pause(); + document.body.onclick = undefined; + } + return; + } + audioData.push(value); + return reader.read().then(processAudio); + }); +} + +async function get_transcription(headers, audioBlob) { + const formData = new FormData(); + formData.append('file', audioBlob, 'recording.webm'); + formData.append('model', 'whisper-1'); + const response = await fetch('https://api.openai.com/v1/audio/transcriptions', { + headers, + method: 'POST', + body: formData + }) + const data = await response.json() + return data.text +} + +async function get_vectors(headers, input) { + const response = await fetch("https://api.openai.com/v1/embeddings", { + headers: { + ...headers, + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify({ + input, + model: 'text-embedding-ada-002' + }) + }) + const embeddings = await response.json() + if (embeddings && embeddings.data) { + return input.map((input, i) => ({ + embedding: embeddings.data[i].embedding, + text: input + })) + } else { + return [] + } +} \ No newline at end of file diff --git a/js/vectors.js b/js/vectors.js new file mode 100644 index 0000000..0120dac --- /dev/null +++ b/js/vectors.js @@ -0,0 +1,84 @@ +export default function createVectorStore(getEmbeddings) { + const db = []; + return { + vectorSearch: vector_search.bind(null, db, getEmbeddings), + storeDocument: store_document.bind(null, db, getEmbeddings), + storeVector: store_vector.bind(null, db), + retrieveVectors: retrieve_vectors.bind(null, db) + } +} + +async function vector_search(embeddingsDB, getEmbeddings, text) { + const embeddings = await getEmbeddings([text]); + const single = embeddings[0]; + if (!single) return []; + const results = retrieve_vectors(embeddingsDB, single.embedding); + return results; +} + +async function store_document(embeddingsDB, getEmbeddings, name, text) { + for (const chunk of chunk_text(text, 500)) { + if (chunk.length == 0) break; + if (!chunk.join('').trim()) break; + const embeddings = await getEmbeddings(chunk); + for (const { embedding, text } of embeddings) { + store_vector(embeddingsDB, name, text, embedding); + } + } +} + +function store_vector(embeddingsDB, name, text, embedding) { + embeddingsDB.push({ name, text, embedding }); +} + +function retrieve_vectors(embeddingsDB, target_embedding, count = 5) { + const results = embeddingsDB.map(({ name, text, embedding }) => ({ + name, + text, + relevance: cosine_similarity(embedding, target_embedding), + })).slice(0, count); + results.sort((a, b) => b.relevance - a.relevance); + return results; +} + +function cosine_similarity(vecA, vecB) { + let dotProduct = 0.0, normA = 0.0, normB = 0.0; + for (let i = 0; i < vecA.length; i++) { + dotProduct += vecA[i] * vecB[i]; + normA += vecA[i] * vecA[i]; + normB += vecB[i] * vecB[i]; + } + if (normA === 0 || normB === 0) return 0; + return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); +} + +function* chunk_text(text, maxChunkSize) { + const sentences = text.match(/[^.!?]+[.!?]*/g) || [text]; + let chunk = ''; + let batch = []; + const maxBatchSize = 20; + + for (let sentence of sentences) { + sentence = sentence.trim(); + if ((chunk + ' ' + sentence).length <= maxChunkSize) { + chunk += ' ' + sentence; + } else { + if (chunk.trim()) { + batch.push(chunk.trim()); + if (batch.length === maxBatchSize) { + yield batch; + batch = []; + } + } + chunk = sentence; + } + } + + if (chunk.trim()) { + batch.push(chunk.trim()); + } + + if (batch.length) { + yield batch; + } +} \ No newline at end of file diff --git a/single_page.html b/single_page.html new file mode 100644 index 0000000..1af303e --- /dev/null +++ b/single_page.html @@ -0,0 +1,877 @@ + + + + + + + gpt-spa - Scalable Dynamics + + + + +
+ + + + + + + + +
+
+ + + + + + \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..46d4bd0 --- /dev/null +++ b/styles.css @@ -0,0 +1,432 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: Arial, Helvetica, sans-serif; + font-size: 16px; + line-height: 1.6; + background-color: #1A202C; + color: #fff; + display: flex; + flex-direction: column; + height: 100vh; + overflow-y: auto; +} + +.container { + display: flex; + flex-grow: 1; +} + +aside { + width: 250px; + padding: 20px; + background-color: #000; + color: #fff; + height: 100vh; + overflow-y: auto; + position: fixed; +} + +aside h2 { + margin-bottom: 20px; +} + +aside ul { + list-style: none; +} + +aside ul li { + margin-bottom: 2px; +} + +aside ul li a { + color: #fff; + text-decoration: none; + display: block; + padding: 4px 6px; + line-height: 30px; +} + +aside ul li a img { + width: 24px; + height: 24px; + margin-right: 10px; + vertical-align: middle; + border-radius: 50%; +} + +aside ul li a:hover { + color: #f4f4f4; + background-color: #1A202C; + border-radius: 6px; +} + +main { + flex-grow: 1; + padding: 20px; + position: relative; + margin-left: 250px; +} + +main h1 { + position: absolute; +} + +.tabs { + background: #4A5568; + width: 350px; + margin: 0 auto; + border-radius: 6px; +} + +.tab { + display: inline-block; +} + +.tab>label { + background: transparent; + padding: 10px; + font-size: 18px; + width: 150px; + height: 40px; + line-height: 32px; + text-align: center; + display: inline-block; + cursor: pointer; + padding: 4px 20px; + margin: 6px 10px; +} + +.tab>[type=radio] { + display: none; +} + +.content { + position: absolute; + inset: 0; + top: 100px; + padding: 20px; + display: none; +} + +[type=radio]:checked~label { + background: #2D3748; + border-radius: 6px; +} + +[type=radio]:checked~label~.content { + display: block; +} + +.output-container { + padding: 20px; + padding-bottom: 100px; +} + +.output-message { + background: #2D3748; + border-radius: 10px; + padding: 15px; + margin-bottom: 15px; +} + +.output-message h3 { + font-size: 18px; + margin-bottom: 10px; +} + +.output-message p { + font-size: 16px; + line-height: 1.4; +} + + +.output-message img { + max-width: 256px; + height: auto; +} + +.input-container { + position: fixed; + bottom: 0; + left: 250px; + right: 0; + height: 50px; +} + +.input-container .input-content { + position: relative; + min-width: 500px; + width: 50%; + margin: 0 auto; +} + +.input-container label input { + display: none; +} + +.input-content>textarea, +.input-content>input[type="text"] { + padding: 4px 40px; + height: 40px; + line-height: 30px; +} + +.input-container button, +.input-container label { + background-color: transparent; + border: none; + cursor: pointer; + position: absolute; + top: 4px; + line-height: 30px; + height: 40px; + width: 40px; + padding: 4px 6px; + z-index: 1; +} + +.input-container label { + left: 2px; +} + +.input-container button { + right: 2px; +} + +.form { + width: 50%; + margin: 20px auto; +} + +textarea, +select, +input[type="text"], +input[type="file"] { + width: 100%; + padding: 10px; + margin-bottom: 10px; + border-radius: 5px; + border: 1px solid #ccc; + background-color: #2D3748; + color: #fff; + outline: none; + resize: none; +} + +aside button, +.form button, +.ac-pushButton { + background-color: #10A37F; + border: none; + color: #fff; + border-radius: 6px; + cursor: pointer; + margin: 0 auto; + margin-top: 12px; + display: block; + width: 100%; + height: 30px; + padding: 4px 6px; +} + +.form label { + display: block; +} + +.form button { + display: inline-block; + margin: 5px; + width: 150px; +} + +.buttons { + margin: 20px 0; +} + +#delete-button { + background-color: #E53E3E; +} + +#cancel-button { + background-color: #718096; +} + +#cancel-button, +#save-button { + float: right; +} + +.input-container { + position: fixed; + left: 250px; + bottom: 0; + right: 25px; + background-color: #1A202C; + padding: 10px; + height: 80px; +} + +.input-content { + display: flex; + gap: 10px; + align-items: center; +} + +.ac-adaptiveCard { + background-color: #4A5568 !important; + color: white !important; + border-radius: 10px; + padding: 15px; + margin-bottom: 15px; +} + +.ac-textBlock, +.ac-textRun { + color: white !important; +} + +.ac-pushButton { + margin-top: 0; +} + +#sidebar-toggle, +#sidebar-toggle~label { + display: none; +} + +.loader { + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} + +.loader div { + position: absolute; + top: 33px; + width: 13px; + height: 13px; + border-radius: 50%; + background: white; + animation-timing-function: cubic-bezier(0, 1, 1, 0); +} + +.loader div:nth-child(1) { + left: 8px; + animation: loader1 0.6s infinite; +} + +.loader div:nth-child(2) { + left: 8px; + animation: loader2 0.6s infinite; +} + +.loader div:nth-child(3) { + left: 32px; + animation: loader2 0.6s infinite; +} + +.loader div:nth-child(4) { + left: 56px; + animation: loader3 0.6s infinite; +} + +@keyframes loader1 { + 0% { + transform: scale(0); + } +} + +@keyframes loader3 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes loader2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} + +@media (max-width: 768px) { + aside { + padding: 10px; + left: -250px; + z-index: 100; + transition: left 0.3s ease-in-out; + } + + #sidebar-toggle:checked~aside { + left: 0; + } + + #sidebar-toggle~label { + position: fixed; + top: 27px; + left: 8px; + z-index: 99; + font-size: 24px; + display: block; + } + + #sidebar-toggle:checked~label { + inset: 0; + } + + main { + margin-left: 0; + } + + main h1 { + display: none; + } + + .content { + top: 50px; + } + + .input-container { + flex-direction: column; + align-items: center; + left: 0; + right: 0; + } + + .input-container .input-content { + flex-direction: column; + width: 100%; + min-width: auto; + margin: 0; + } + + .form { + width: 100%; + } + + .form button { + width: calc((100% - 80px) / 3); + } + .form label, + .form textarea, + .form select, + .form .buttons, + .form input[type="text"], + .form input[type="file"] { + width: calc(100% - 60px); + margin-left: 30px; + } +} \ No newline at end of file