From 8148cfae1805aa00088ade87cc386dd578ea74d3 Mon Sep 17 00:00:00 2001 From: jwaf Date: Tue, 5 Dec 2023 15:11:03 -0500 Subject: [PATCH 1/7] pkg json changes for new build --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6a16d26..6a8aa85 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "ts-node": "^10.9.1" }, "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@types/node": "^20.8.7" }, "browserslist": { From 4042b8b3d96f32ee1a66b38578de089bfa1ab024 Mon Sep 17 00:00:00 2001 From: Rohan Mishra <315scisyb2020rohanmishra@gmail.com> Date: Mon, 18 Dec 2023 18:55:35 +0530 Subject: [PATCH 2/7] Create build-test.yml Created the build-test.yml file. --- .github/workflows/build-test.yml | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/build-test.yml diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 0000000..1d6b367 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,37 @@ +name: Build and Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: 14 + + - name: Install dependencies + run: npm install + + - name: Create test directory + run: mkdir test + + - name: Create test file + run: echo "This is a test" > test/test.js + + - name: Build project + run: npm run build -- --include=test + + - name: Run tests + run: npm test From c745d5a9bc781b2c236b286a866704afdf88420d Mon Sep 17 00:00:00 2001 From: jwaf Date: Wed, 20 Dec 2023 02:08:55 -0500 Subject: [PATCH 3/7] copilot conversations added, tracking ids and getting proper responses back - need some assistance with stream but have basic setup for 1 - 1 conversations --- package.json | 72 +++--- src/app/App.tsx | 401 +++++++++++++++++---------------- src/app/components/Copilot.tsx | 230 +++++++++++++++++++ 3 files changed, 475 insertions(+), 228 deletions(-) create mode 100644 src/app/components/Copilot.tsx diff --git a/package.json b/package.json index 330699c..80de4d9 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,36 @@ -{ - "name": "testing-sdk", - "version": "1.0.0", - "main": "src/index.tsx", - "scripts": { - "dev": "ts-node src/index.tsx", - "clean": "rm -r node_modules && rm package-lock.json", - "start": "react-scripts start", - "build": "react-scripts build" - }, - "dependencies": { - "@pieces.app/pieces-os-client": "^1.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-scripts": "^5.0.1", - "ts-node": "^10.9.1" - }, - "devDependencies": { - "@types/node": "^20.8.7", - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "typescript": "^4.9.5" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - } -} +{ + "name": "testing-sdk", + "version": "1.0.0", + "main": "src/index.tsx", + "scripts": { + "dev": "ts-node src/index.tsx", + "clean": "rm -r node_modules && rm package-lock.json", + "start": "react-scripts start", + "build": "react-scripts build" + }, + "dependencies": { + "@pieces.app/pieces-os-client": "1.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "^5.0.1", + "ts-node": "^10.9.1" + }, + "devDependencies": { + "@types/node": "^20.8.7", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "typescript": "^4.9.5" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index c35c550..d5c10c3 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,193 +1,210 @@ -import * as React from "react"; -import {useState, useEffect} from "react"; -import * as Pieces from "@pieces.app/pieces-os-client"; -import {Application} from "@pieces.app/pieces-os-client"; -import {DataTextInput, DeleteAssetButton, RenameAssetInput} from './components/TextInput'; -import {Header} from './components/Header' -import {connect} from './utils/Connect' - -// types -type LocalAsset = { - name: string, - id: string, - classification: Pieces.ClassificationSpecificEnum, -} - -//=============================[GLOBALS]================================// -let full_context: JSON; -let applicationData: Application; -let _indicator: HTMLElement; -let snippetList: Array; - -// you primary App function where all react elements render at their core. -// for reactivity i added a number of state based properties here, and made the highest level -// a little more verbose. -// -// refresh is responsible for adding new assets to the snippet list itself when you create a new asset. -// the refresh button is connected to first - - - -
- {array.map((item: LocalAsset, index) => ( -
handleSelect(index)} - > -
-

{item.name}

-
-

{item.id}

-

{item.classification}

-
-
-
- - ))} - - {/* (5) @jordan-pieces moved the refresh button up higher in this file and placed it inside the same column div as the snippet list. - this will create the more side by side look that you were going for in your design example */} -
- - -
-

Create a New Snippet

- - - -
- - - - - ) -} - -connect().then(__ => { - // TODO: this needs improvement but is okay for now. since the types are not here yet, - // for some reason i had to parse the stringified full_context object to get correct typing. - full_context = __; - let _t = JSON.parse(JSON.stringify(full_context)); - applicationData = _t.application; - console.log('Application Data: ', applicationData); - - // define the indicator now that it exists. - _indicator = document.getElementById("indicator"); - - // conditional for the response back on application. - // - // (1) first @jordan-pieces came in here and added this turing statement here inside a new - // if statement. this is an upgrade in comparison to the previous if statement that would not check to - // see if the _indicator itself is added to the DOM yet. - if (_indicator != null) { - __ != undefined ? _indicator.style.backgroundColor = "green" : _indicator.style.backgroundColor = "red"; - } +import * as React from "react"; +import {useState, useEffect} from "react"; +import * as Pieces from "@pieces.app/pieces-os-client"; +import {Application} from "@pieces.app/pieces-os-client"; +import {DataTextInput, DeleteAssetButton, RenameAssetInput} from './components/TextInput'; +import {Header} from './components/Header' +import {CopilotChat} from './components/Copilot' +import {connect} from './utils/Connect' + +// types +type LocalAsset = { + name: string, + id: string, + classification: Pieces.ClassificationSpecificEnum, +} + +//=============================[GLOBALS]================================// +let full_context: JSON; +export var applicationData: Application; +let _indicator: HTMLElement; +let snippetList: Array; + +// you primary App function where all react elements render at their core. +// for reactivity i added a number of state based properties here, and made the highest level +// a little more verbose. +// +// refresh is responsible for adding new assets to the snippet list itself when you create a new asset. +// the refresh button is connected to first + + + +
+ {array.map((item: LocalAsset, index) => ( +
handleSelect(index)} + > +
+

{item.name}

+
+

{item.id}

+

{item.classification}

+
+
+
+ + ))} + + {/* (5) @jordan-pieces moved the refresh button up higher in this file and placed it inside the same column div as the snippet list. + this will create the more side by side look that you were going for in your design example */} +
+ + +
+

Create a New Snippet

+ + + +
+ + + {/* this is the copilot container. the copilot logic is inside of /components/Copilot.tsx */} +
+ +
+ + + + ) +} + +connect().then(__ => { + // TODO: this needs improvement but is okay for now. since the types are not here yet, + // for some reason i had to parse the stringified full_context object to get correct typing. + full_context = __; + let _t = JSON.parse(JSON.stringify(full_context)); + applicationData = _t.application; + console.log('Application Data: ', applicationData); + + // define the indicator now that it exists. + _indicator = document.getElementById("indicator"); + + // conditional for the response back on application. + // + // (1) first @jordan-pieces came in here and added this turing statement here inside a new + // if statement. this is an upgrade in comparison to the previous if statement that would not check to + // see if the _indicator itself is added to the DOM yet. + if (_indicator != null) { + __ != undefined ? _indicator.style.backgroundColor = "green" : _indicator.style.backgroundColor = "red"; + } }) \ No newline at end of file diff --git a/src/app/components/Copilot.tsx b/src/app/components/Copilot.tsx new file mode 100644 index 0000000..1b5b7c7 --- /dev/null +++ b/src/app/components/Copilot.tsx @@ -0,0 +1,230 @@ +import * as React from 'react' +import {useEffect, useState} from 'react' +import * as Pieces from "@pieces.app/pieces-os-client"; +import {ConversationTypeEnum, SeededConversation} from "@pieces.app/pieces-os-client"; + +import { applicationData } from "../App"; + + +let GlobalConversationID: string; +let GlobalConversationAnswers: Array = []; + +// this gets all conversations that are currently available for a user. there is an optional param that you can set to +// also return the first asset in the list. +// TODO: any is baaaad here but coming back to clean up. output is either void or the first conversation in the list. + +// export function refreshCopilot(): void { +// getAllConversations(true) +// } +// +// +// export function getAllConversations(firstOnly?: boolean): any { +// new Pieces.ConversationsApi().conversationsSnapshot({}).then(output => { +// // exit here quickly if the first flag is not set. +// if (firstOnly == false){ +// console.log(output) +// } else { +// return output.iterable.at(0); +// } +// }) +// } + + +// going to use get all conversations with a few extra steps to store the current conversations locally. + +export function createNewConversation() { + + console.log('Begin creating conversation...') + // variables for storing our values as they come in. + // let iterable: Array; + + // to create a new conversation, you need to first pass in a seeded conversation in the request body. + // the only mandatory parameter is the ConversationTypeEnum.Copilot value. + let seededConversation: SeededConversation = { type: ConversationTypeEnum.Copilot, name: "Demo Seeded Conversation" } + + console.log('Conversation seeded') + console.log('Passing over the new conversation with name: ' + seededConversation.name) + + // creates new conversation, .then is for confirmation on creation. + // note the usage of transfereables here to expose the full conversation data and give access to the id and other + // conversation values. + new Pieces.ConversationsApi().conversationsCreateSpecificConversationRaw({transferables: true, seededConversation}).then((_c) => { + console.log('Conversation created! : Here is the response:'); + console.log(_c); + + // check and ensure the response back is clean. + if (_c.raw.ok == true && _c.raw.status == 200) { + console.log('CLEAN RESPONSE BACK.') + _c.value().then(_conversation => { + console.log('Returning new conversation values:') + console.log('ID | ' + _conversation.id); + + // we also are going to set the current conversation id globally here: + GlobalConversationID = _conversation.id; + + console.log('NAME | ' + _conversation.name); + console.log('CREATED | ' + _conversation.created.readable); + // console.log('ID: ' + _conversation.); + }) + } + }) +} + + + +function sendConversationMessage(prompt: string, conversationID: string = GlobalConversationID){ + // 1. seed a message + // 2. get the conversation id from somewhere - likely the createNewConversation above ^^ + // 3. send the new message over + // 4. use the message contents in the (not yet created) message stream/list + console.log(prompt); + console.log(conversationID); + + askQuestion({query: prompt, relevant: ''}).then((r) => { + return r.result; + }).then((value) => { + // TODO: need to collect all of the iterable answers here for the viewer. + // TODO: i believe this is near the implementation of the stream. + let _answers = value.answers.iterable; + // console.log(_answers); + + // let's store the new answers globally for this file: + GlobalConversationAnswers = [..._answers]; + }) +} + +function ChatsComponent() { + + return
+ {GlobalConversationAnswers.map((item: Pieces.QGPTQuestionAnswer, index) => ( +
+ ))}; +
+} + +export async function askQuestion({ + query, + relevant, + }: { + query: string; + relevant: string; +}) { + // TODO: need to get instance here - current config is stored in app.tsx (maybe not actually) + // const config = ConnectorSingleton.getInstance(); + const params: Pieces.QGPTQuestionInput = { + query, + relevant: { + iterable: [ + { + seed: { + type: Pieces.SeedTypeEnum.Asset, + asset: { + application: applicationData, + format: { + fragment: { + string: { + raw: relevant, + }, + }, + }, + }, + }, + }, + ], + }, + }; + // const result = await Pieces.QGPTApi.question({qGPTQuestionInput: params}); + const result = new Pieces.QGPTApi().question({qGPTQuestionInput: params}); + return {result, query}; +} + +export function CopilotChat(): React.JSX.Element { + const [chatSelected, setChatSelected] = useState('-- no chat selected --'); + const [chatInputData, setData] = useState(''); + + // handles the data changes on the chat input. + const handleCopilotChatInputChange = (event: { target: { value: React.SetStateAction; }; }) => { + setData(event.target.value); + // console.log(event.target.value) + }; + + // for setting the initial copilot chat that takes place on page load. + useEffect(() => { + const getInitialChat = async () => { + let _name: string; + + await new Pieces.ConversationsApi().conversationsSnapshot({}).then(output => { + _name = output.iterable.at(0).name; + GlobalConversationID = output.iterable.at(0).id; + return output.iterable.at(0).name + }); + setChatSelected(_name); + }; + getInitialChat(); + }, []); + + return ( +
+
+
+

Copilot Chat

+ {/* TODO: configure for selecting a conversation */} + {/**/} + +
+ +
+ {/* TODO: great candidate for a class here on these two buttons and their coming styles. */} + + {/* TODO: attach the data here for the currently selected copilot chat */} +

{chatSelected}

+ +
+
+
+ {/* top layer is the text box that always stays on top of the chat bar */} +
+ + +
+ {/* this is the bottom container that is where the messages go. */} +
+
+ +
+
+ +
+
+ ) +} From 3f4e1463f744a9b8c5ff75dbfbfb6fb3d72e8434 Mon Sep 17 00:00:00 2001 From: jwaf Date: Wed, 20 Dec 2023 15:17:19 -0500 Subject: [PATCH 4/7] added stream controller initialized - no config yet though --- src/app/components/Copilot.tsx | 21 +- .../controllers/copilotStreamController.tsx | 388 ++++++++++++++++++ 2 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 src/app/controllers/copilotStreamController.tsx diff --git a/src/app/components/Copilot.tsx b/src/app/components/Copilot.tsx index 1b5b7c7..5ab0b1f 100644 --- a/src/app/components/Copilot.tsx +++ b/src/app/components/Copilot.tsx @@ -3,11 +3,12 @@ import {useEffect, useState} from 'react' import * as Pieces from "@pieces.app/pieces-os-client"; import {ConversationTypeEnum, SeededConversation} from "@pieces.app/pieces-os-client"; + import { applicationData } from "../App"; let GlobalConversationID: string; -let GlobalConversationAnswers: Array = []; +// let GlobalConversationAnswers: Array = []; // this gets all conversations that are currently available for a user. there is an optional param that you can set to // also return the first asset in the list. @@ -89,19 +90,19 @@ function sendConversationMessage(prompt: string, conversationID: string = Global // console.log(_answers); // let's store the new answers globally for this file: - GlobalConversationAnswers = [..._answers]; + // GlobalConversationAnswers = [..._answers]; }) } function ChatsComponent() { - return
- {GlobalConversationAnswers.map((item: Pieces.QGPTQuestionAnswer, index) => ( -
- ))}; -
+ // return
+ // {GlobalConversationAnswers.map((item: Pieces.QGPTQuestionAnswer, index) => ( + //
+ // ))}; + //
} export async function askQuestion({ @@ -219,7 +220,7 @@ export function CopilotChat(): React.JSX.Element { {/* this is the bottom container that is where the messages go. */}
-
+
diff --git a/src/app/controllers/copilotStreamController.tsx b/src/app/controllers/copilotStreamController.tsx new file mode 100644 index 0000000..f82c78d --- /dev/null +++ b/src/app/controllers/copilotStreamController.tsx @@ -0,0 +1,388 @@ +/* eslint-disable @typescript-eslint/comma-dangle */ +/* eslint-disable comma-dangle */ + +import * as Pieces from "@pieces.app/pieces-os-client"; + +export type MessageOutput = { + answer: string; + relevant: Pieces.RelevantQGPTSeeds; + queryId: string; + answerId: string; +}; + +/** + * Stream controller class for interacting with the QGPT websocket + */ +export default class CopilotStreamController { + private static instance: CopilotStreamController; + + private ws: WebSocket | null = null; // the qgpt websocket + + private answerEl: HTMLElement | null = null; // the current answer element to be updated from socket events + + // this will resolve the current promise that is created by this.handleMessage + private messageResolver: null | ((arg0: MessageOutput) => void) = null; + + // this will reject the current promise that is created by this.handleMessage + private messageRejector: null | ((arg0: any) => void) = null; + + // this is resolved when the socket is ready. + private connectionPromise: Promise = new Promise((res) => res); + + //@TODO implement socket unloading + private constructor() { + this.connect(); + } + + /** + * cleanup function + */ + public closeSocket() { + this.ws?.close(); + } + + /** + * This is the entry point for all chat messages into this socket. + * @param param0 The inputted user query, any relevant snippets, and the answer element to be updated + * @returns a promise which is resolved when we get a 'COMPLETED' status from the socket, or rejected on a socket error. + */ + public async askQGPT({ + query, + answerEl, + }: { + query: string; + answerEl: HTMLElement; + }): Promise { + if (!this.ws) { + this.connect(); + } // need to connect the socket if it's not established. + await fetch(`http://localhost:${port}/.well-known/health`).catch(() => { + return connectionPoller(); + }); + const application = await getApplication(); + if (!application) return; + + const relevanceInput: RelevanceRequest = { + qGPTRelevanceInput: { + query, + paths: QGPTView.contextSelectionModal.paths, + assets: { iterable: QGPTView.contextSelectionModal.assets }, + messages: { iterable: QGPTView.contextSelectionModal.grounding }, + }, + }; + + const relevanceOutput = + await ConnectorSingleton.getInstance().QGPTApi.relevance(relevanceInput); + + if (QGPTView.relevant) { + relevanceOutput.relevant.iterable.push({ + seed: { + type: SeedTypeEnum.Asset, + asset: { + application, + format: { + fragment: { + string: { + raw: Pieces.QGPTView.relevant.text, + }, + metadata: { + ext: QGPTView.relevant.extension, + }, + }, + }, + }, + }, + }); + } + + for (const codeBlock of QGPTView.codeBlocks) { + relevanceOutput.relevant.iterable.push({ + seed: { + type: SeedTypeEnum.Asset, + asset: { + application, + format: { + fragment: { + string: { + raw: codeBlock.text, + }, + metadata: { + ext: codeBlock.extension, + }, + }, + }, + }, + }, + }); + } + + const relevantEl = QGPTViewBuilder.buildRelevantElement({ + answerDiv: answerEl.parentElement! as HTMLDivElement, + files: relevanceOutput.relevant.iterable, + }); + + if (relevantEl) { + const isAtBottom = this.isAtBottom(answerEl); + answerEl.parentElement?.parentElement?.parentElement?.appendChild( + relevantEl + ); + if (isAtBottom) this.forceScroll(answerEl); + } + + const input: QGPTStreamInput = { + question: { + query, + relevant: relevanceOutput.relevant, + model: CopilotLLMConfigModal.selectedModel + ? CopilotLLMConfigModal.selectedModel + : undefined, + }, + conversation: QGPTView.conversationId, + }; + + return this.handleMessages({ input, answerEl }); + } + + /** + * If the user has not used their mousewheel, scroll their container to the bottom. + * @param answerEl The answer element that is being updated + * @returns void + */ + public forceScroll(answerEl: HTMLElement) { + if (!answerEl.parentElement?.parentElement?.parentElement) + throw new Error( + 'Change in dom structure broke our autoscroll behavior in the Copilot Stream Controller' + ); + answerEl.parentElement.parentElement.parentElement.onscroll = () => { + this.hasScrolled = true; + }; + answerEl.parentElement.parentElement.parentElement.scrollTop = + answerEl.parentElement.parentElement.parentElement.scrollHeight; + } + + public isAtBottom(answerEl: HTMLElement): boolean { + if (!answerEl.parentElement?.parentElement?.parentElement) + throw new Error( + 'Change in dom structure broke our autoscroll behavior in the Copilot Stream Controller' + ); + const element = answerEl.parentElement.parentElement.parentElement; + const scrollHeight = element.scrollHeight; + const scrollTop = element.scrollTop; + const offsetHeight = element.offsetHeight; + + if (offsetHeight === 0) { + return true; + } + + return scrollTop >= scrollHeight - offsetHeight - 1; + } + + /** + * Connects the websocket, handles all message callbacks, error handling, and rendering. + */ + private connect() { + this.ws = new WebSocket(`ws://localhost:${port}/qgpt/stream`); + + let totalMessage = ''; + let relevantSnippets: RelevantQGPTSeed[] = []; + + this.ws.onmessage = (msg) => { + const json = JSON.parse(msg.data); + const result = QGPTStreamOutputFromJSON(json); + let answer: QGPTQuestionAnswer | undefined; + let relevant: RelevantQGPTSeeds | undefined; + + // we got something from /relevance + if (result.relevance) { + relevant = result.relevance.relevant; + } else { + relevant = { iterable: [] }; + } + + // there is relevant snippets from the socket + if (relevant) { + for (const el of relevant.iterable) { + relevantSnippets.push(el); + } + } + // we got something from /question + if (result.question) { + answer = result.question.answers.iterable[0]; + } else { + // the message is complete, or we do nothing + if (result.status === 'COMPLETED') { + QGPTView.lastConversationMessage = new Date(); + // add the buttons to the answer element's code blocks. + if (!totalMessage) { + this.answerEl!.innerHTML = + "I'm sorry, it seems I don't have any relevant context to that question. Please try again 😃"; + } + let queryIndicie: [string, number] = ['', -1]; + let answerIndicie: [string, number] = ['', -1]; + ConnectorSingleton.getInstance() + .conversationApi.conversationGetSpecificConversation({ + conversation: result.conversation, + }) + .then((conversation) => { + // get the latest two messages on the conversation + // this assumes the last message indicie is our answerid + // and the 2nd to last message indicie is our queryid + // this is the most efficient way I could figure this out - caleb + for (const key in conversation.messages.indices ?? {}) { + const index = conversation.messages.indices![key]; + if (index > answerIndicie[1]) { + queryIndicie = answerIndicie; + answerIndicie = [key, index]; + } else if (index > queryIndicie[1]) { + queryIndicie = [key, index]; + } + } + }) + .finally(() => { + // render the new total message + const isAtBottom = this.isAtBottom(this.answerEl!); + this.handleRender( + totalMessage, + this.answerEl!, + relevantSnippets, + true + ); + if (isAtBottom) this.forceScroll(this.answerEl!); + QGPTViewBuilder.addMessageActions( + this.answerEl!.parentElement!, + false, + answerIndicie[0] + ); + this.messageResolver!({ + answer: totalMessage, + relevant: { iterable: relevantSnippets }, + answerId: answerIndicie[0], + queryId: queryIndicie[0], + }); + // cleanup + totalMessage = ''; + relevantSnippets = []; + }); + } else if (result.status === 'FAILED' || result.status === 'UNKNOWN') { + if (this.messageRejector) this.messageRejector(result); + totalMessage = ''; + relevantSnippets = []; + } + return; + } + // add to the total message + if (answer?.text) { + totalMessage += answer.text; + } + // render the new total message + const isAtBottom = this.isAtBottom(this.answerEl!); + this.handleRender(totalMessage, this.answerEl!, relevantSnippets); + if (isAtBottom) this.forceScroll(this.answerEl!); + }; + + const refreshSockets = (error?: any) => { + if (error) console.error(error); + totalMessage = ''; + relevantSnippets = []; + if (this.messageRejector) this.messageRejector(error); + this.ws = null; + }; + // on error or close, reject the 'handleMessage' promise, and close the socket. + this.ws.onerror = refreshSockets; + this.ws.onclose = refreshSockets; + + this.connectionPromise = new Promise((res) => { + if (!this.ws) + throw new Error( + 'There is no websocket in Copilot Stream Controller (race condition)' + ); + this.ws.onopen = () => res(); + }); + } + + /** + * + * @param param0 the input into the websocket, and the answer element to be updated. + * @returns a promise that is resolved when the chat is complete, or rejected on an error. + */ + private async handleMessages({ + input, + answerEl, + }: { + input: QGPTStreamInput; + answerEl: HTMLElement; + }) { + if (!this.ws) this.connect(); + ConversationStreamController.getInstance().resetConnection(); + await this.connectionPromise; + this.answerEl = answerEl; + + // scroll the container to the bottom + answerEl!.parentElement!.parentElement!.scrollTop = + answerEl!.parentElement!.parentElement!.scrollHeight; + this.hasScrolled = false; + + // init message promise + const promise = new Promise((res, rej) => { + this.messageResolver = res; + this.messageRejector = rej; + }); + + // seed the next conversation if there is not one. + if (!QGPTView.conversationId.length) { + const seededConversation: SeededConversation = { + type: ConversationTypeEnum.Copilot, + }; + const conversation = + await ConnectorSingleton.getInstance().conversationsApi.conversationsCreateSpecificConversation( + { + transferables: false, + seededConversation, + } + ); + input.conversation = conversation.id; + QGPTView.conversationId = conversation.id; + } else { + input.conversation = QGPTView.conversationId; + } + try { + QGPTView.lastConversationMessage = new Date(); + this.ws!.send(JSON.stringify(input)); + } catch (err) { + console.error('err', err); + this.messageRejector?.(err); + } + + return promise; + } + + /** + * This converts our raw markdown into HTML, then syntax highlights the pre > code blocks, then renders the result. + * @param totalMessage The total message to rendre + * @param answerEl the answer element to update + */ + private handleRender( + totalMessage: string, + answerEl: HTMLElement, + relevant: RelevantQGPTSeed[], + completed = false + ) { + const sanitized = QGPTComponentBuilder.sanitize(totalMessage); + const htmlString = marked.parse(sanitized); + const div = document.createElement('div'); + div.innerHTML = htmlString; + QGPTViewBuilder.highlightCodeBlocks( + Array.from(div.querySelectorAll('pre > code')), + relevant, + !completed + ); + + div.classList.add(...answerEl.classList); + answerEl.replaceWith(div); + this.answerEl = div; + } + + public static getInstance() { + return (CopilotStreamController.instance ??= new CopilotStreamController()); + } +} From fa7c40469e1df2626b4dead7deaee548b3bdfdc9 Mon Sep 17 00:00:00 2001 From: caleb-at-pieces Date: Wed, 20 Dec 2023 16:07:15 -0500 Subject: [PATCH 5/7] feat: working copilot example --- src/app/App.tsx | 2 + src/app/components/Copilot.tsx | 6 +- .../controllers/copilotStreamController.tsx | 254 +++--------------- 3 files changed, 39 insertions(+), 223 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index d5c10c3..d64470a 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -6,6 +6,7 @@ import {DataTextInput, DeleteAssetButton, RenameAssetInput} from './components/T import {Header} from './components/Header' import {CopilotChat} from './components/Copilot' import {connect} from './utils/Connect' +import CopilotStreamController from "./controllers/copilotStreamController"; // types type LocalAsset = { @@ -37,6 +38,7 @@ export function App(): React.JSX.Element { useEffect(() => { refreshSnippetList(); + CopilotStreamController.getInstance(); }, []); const clearArray = () => { diff --git a/src/app/components/Copilot.tsx b/src/app/components/Copilot.tsx index 5ab0b1f..039b1f7 100644 --- a/src/app/components/Copilot.tsx +++ b/src/app/components/Copilot.tsx @@ -5,6 +5,7 @@ import {ConversationTypeEnum, SeededConversation} from "@pieces.app/pieces-os-cl import { applicationData } from "../App"; +import CopilotStreamController from '../controllers/copilotStreamController'; let GlobalConversationID: string; @@ -144,6 +145,7 @@ export async function askQuestion({ export function CopilotChat(): React.JSX.Element { const [chatSelected, setChatSelected] = useState('-- no chat selected --'); const [chatInputData, setData] = useState(''); + const [message, setMessage] = useState(''); // handles the data changes on the chat input. const handleCopilotChatInputChange = (event: { target: { value: React.SetStateAction; }; }) => { @@ -216,12 +218,12 @@ export function CopilotChat(): React.JSX.Element { border: 'none', resize: 'none' }} value={chatInputData} onChange={handleCopilotChatInputChange}> - +
{/* this is the bottom container that is where the messages go. */}
- +

{message}

diff --git a/src/app/controllers/copilotStreamController.tsx b/src/app/controllers/copilotStreamController.tsx index f82c78d..c001dc0 100644 --- a/src/app/controllers/copilotStreamController.tsx +++ b/src/app/controllers/copilotStreamController.tsx @@ -1,13 +1,8 @@ -/* eslint-disable @typescript-eslint/comma-dangle */ -/* eslint-disable comma-dangle */ import * as Pieces from "@pieces.app/pieces-os-client"; export type MessageOutput = { answer: string; - relevant: Pieces.RelevantQGPTSeeds; - queryId: string; - answerId: string; }; /** @@ -18,7 +13,7 @@ export default class CopilotStreamController { private ws: WebSocket | null = null; // the qgpt websocket - private answerEl: HTMLElement | null = null; // the current answer element to be updated from socket events + private setMessage: (message: string) => void; // the current answer element to be updated from socket events // this will resolve the current promise that is created by this.handleMessage private messageResolver: null | ((arg0: MessageOutput) => void) = null; @@ -48,149 +43,43 @@ export default class CopilotStreamController { */ public async askQGPT({ query, - answerEl, + setMessage }: { query: string; - answerEl: HTMLElement; + setMessage: (message: string) => void; }): Promise { if (!this.ws) { this.connect(); } // need to connect the socket if it's not established. - await fetch(`http://localhost:${port}/.well-known/health`).catch(() => { - return connectionPoller(); + await fetch(`http://localhost:1000/.well-known/health`).catch(() => { + // @TODO add error handling here }); - const application = await getApplication(); - if (!application) return; - const relevanceInput: RelevanceRequest = { - qGPTRelevanceInput: { - query, - paths: QGPTView.contextSelectionModal.paths, - assets: { iterable: QGPTView.contextSelectionModal.assets }, - messages: { iterable: QGPTView.contextSelectionModal.grounding }, - }, - }; - - const relevanceOutput = - await ConnectorSingleton.getInstance().QGPTApi.relevance(relevanceInput); - - if (QGPTView.relevant) { - relevanceOutput.relevant.iterable.push({ - seed: { - type: SeedTypeEnum.Asset, - asset: { - application, - format: { - fragment: { - string: { - raw: Pieces.QGPTView.relevant.text, - }, - metadata: { - ext: QGPTView.relevant.extension, - }, - }, - }, - }, - }, - }); - } - - for (const codeBlock of QGPTView.codeBlocks) { - relevanceOutput.relevant.iterable.push({ - seed: { - type: SeedTypeEnum.Asset, - asset: { - application, - format: { - fragment: { - string: { - raw: codeBlock.text, - }, - metadata: { - ext: codeBlock.extension, - }, - }, - }, - }, - }, - }); - } - - const relevantEl = QGPTViewBuilder.buildRelevantElement({ - answerDiv: answerEl.parentElement! as HTMLDivElement, - files: relevanceOutput.relevant.iterable, - }); - - if (relevantEl) { - const isAtBottom = this.isAtBottom(answerEl); - answerEl.parentElement?.parentElement?.parentElement?.appendChild( - relevantEl - ); - if (isAtBottom) this.forceScroll(answerEl); - } - - const input: QGPTStreamInput = { + // @TODO add conversation id + const input: Pieces.QGPTStreamInput = { question: { query, - relevant: relevanceOutput.relevant, - model: CopilotLLMConfigModal.selectedModel - ? CopilotLLMConfigModal.selectedModel - : undefined, + relevant: {iterable: []} //@TODO hook up /relevance here for context }, - conversation: QGPTView.conversationId, }; - return this.handleMessages({ input, answerEl }); - } - - /** - * If the user has not used their mousewheel, scroll their container to the bottom. - * @param answerEl The answer element that is being updated - * @returns void - */ - public forceScroll(answerEl: HTMLElement) { - if (!answerEl.parentElement?.parentElement?.parentElement) - throw new Error( - 'Change in dom structure broke our autoscroll behavior in the Copilot Stream Controller' - ); - answerEl.parentElement.parentElement.parentElement.onscroll = () => { - this.hasScrolled = true; - }; - answerEl.parentElement.parentElement.parentElement.scrollTop = - answerEl.parentElement.parentElement.parentElement.scrollHeight; - } - - public isAtBottom(answerEl: HTMLElement): boolean { - if (!answerEl.parentElement?.parentElement?.parentElement) - throw new Error( - 'Change in dom structure broke our autoscroll behavior in the Copilot Stream Controller' - ); - const element = answerEl.parentElement.parentElement.parentElement; - const scrollHeight = element.scrollHeight; - const scrollTop = element.scrollTop; - const offsetHeight = element.offsetHeight; - - if (offsetHeight === 0) { - return true; - } - - return scrollTop >= scrollHeight - offsetHeight - 1; + return this.handleMessages({ input, setMessage }); } /** * Connects the websocket, handles all message callbacks, error handling, and rendering. */ private connect() { - this.ws = new WebSocket(`ws://localhost:${port}/qgpt/stream`); + this.ws = new WebSocket(`ws://localhost:1000/qgpt/stream`); let totalMessage = ''; - let relevantSnippets: RelevantQGPTSeed[] = []; + let relevantSnippets: Pieces.RelevantQGPTSeed[] = []; this.ws.onmessage = (msg) => { const json = JSON.parse(msg.data); - const result = QGPTStreamOutputFromJSON(json); - let answer: QGPTQuestionAnswer | undefined; - let relevant: RelevantQGPTSeeds | undefined; + const result = Pieces.QGPTStreamOutputFromJSON(json); + let answer: Pieces.QGPTQuestionAnswer | undefined; + let relevant: Pieces.RelevantQGPTSeeds | undefined; // we got something from /relevance if (result.relevance) { @@ -211,58 +100,21 @@ export default class CopilotStreamController { } else { // the message is complete, or we do nothing if (result.status === 'COMPLETED') { - QGPTView.lastConversationMessage = new Date(); // add the buttons to the answer element's code blocks. if (!totalMessage) { - this.answerEl!.innerHTML = - "I'm sorry, it seems I don't have any relevant context to that question. Please try again 😃"; + this.setMessage("I'm sorry, it seems I don't have any relevant context to that question. Please try again 😃") } - let queryIndicie: [string, number] = ['', -1]; - let answerIndicie: [string, number] = ['', -1]; - ConnectorSingleton.getInstance() - .conversationApi.conversationGetSpecificConversation({ - conversation: result.conversation, - }) - .then((conversation) => { - // get the latest two messages on the conversation - // this assumes the last message indicie is our answerid - // and the 2nd to last message indicie is our queryid - // this is the most efficient way I could figure this out - caleb - for (const key in conversation.messages.indices ?? {}) { - const index = conversation.messages.indices![key]; - if (index > answerIndicie[1]) { - queryIndicie = answerIndicie; - answerIndicie = [key, index]; - } else if (index > queryIndicie[1]) { - queryIndicie = [key, index]; - } - } - }) - .finally(() => { - // render the new total message - const isAtBottom = this.isAtBottom(this.answerEl!); - this.handleRender( - totalMessage, - this.answerEl!, - relevantSnippets, - true - ); - if (isAtBottom) this.forceScroll(this.answerEl!); - QGPTViewBuilder.addMessageActions( - this.answerEl!.parentElement!, - false, - answerIndicie[0] - ); - this.messageResolver!({ - answer: totalMessage, - relevant: { iterable: relevantSnippets }, - answerId: answerIndicie[0], - queryId: queryIndicie[0], - }); - // cleanup - totalMessage = ''; - relevantSnippets = []; - }); + + // render the new total message + this.handleRender( + totalMessage, + ); + this.messageResolver!({ + answer: totalMessage, + }); + // cleanup + totalMessage = ''; + } else if (result.status === 'FAILED' || result.status === 'UNKNOWN') { if (this.messageRejector) this.messageRejector(result); totalMessage = ''; @@ -275,9 +127,7 @@ export default class CopilotStreamController { totalMessage += answer.text; } // render the new total message - const isAtBottom = this.isAtBottom(this.answerEl!); - this.handleRender(totalMessage, this.answerEl!, relevantSnippets); - if (isAtBottom) this.forceScroll(this.answerEl!); + this.handleRender(totalMessage); }; const refreshSockets = (error?: any) => { @@ -307,20 +157,14 @@ export default class CopilotStreamController { */ private async handleMessages({ input, - answerEl, + setMessage, }: { - input: QGPTStreamInput; - answerEl: HTMLElement; + input: Pieces.QGPTStreamInput; + setMessage: (message: string) => void; }) { if (!this.ws) this.connect(); - ConversationStreamController.getInstance().resetConnection(); await this.connectionPromise; - this.answerEl = answerEl; - - // scroll the container to the bottom - answerEl!.parentElement!.parentElement!.scrollTop = - answerEl!.parentElement!.parentElement!.scrollHeight; - this.hasScrolled = false; + this.setMessage = setMessage; // init message promise const promise = new Promise((res, rej) => { @@ -328,25 +172,7 @@ export default class CopilotStreamController { this.messageRejector = rej; }); - // seed the next conversation if there is not one. - if (!QGPTView.conversationId.length) { - const seededConversation: SeededConversation = { - type: ConversationTypeEnum.Copilot, - }; - const conversation = - await ConnectorSingleton.getInstance().conversationsApi.conversationsCreateSpecificConversation( - { - transferables: false, - seededConversation, - } - ); - input.conversation = conversation.id; - QGPTView.conversationId = conversation.id; - } else { - input.conversation = QGPTView.conversationId; - } try { - QGPTView.lastConversationMessage = new Date(); this.ws!.send(JSON.stringify(input)); } catch (err) { console.error('err', err); @@ -363,23 +189,9 @@ export default class CopilotStreamController { */ private handleRender( totalMessage: string, - answerEl: HTMLElement, - relevant: RelevantQGPTSeed[], - completed = false ) { - const sanitized = QGPTComponentBuilder.sanitize(totalMessage); - const htmlString = marked.parse(sanitized); - const div = document.createElement('div'); - div.innerHTML = htmlString; - QGPTViewBuilder.highlightCodeBlocks( - Array.from(div.querySelectorAll('pre > code')), - relevant, - !completed - ); - - div.classList.add(...answerEl.classList); - answerEl.replaceWith(div); - this.answerEl = div; + // this is set up to only do one dom change, that way we prevent flickering in the case we want to do markdown parsing or syntax highlighting + this.setMessage?.(totalMessage); } public static getInstance() { From a09cd9d4fb381df0abe0ffdcffe0f6766e475816 Mon Sep 17 00:00:00 2001 From: jwaf Date: Thu, 21 Dec 2023 12:08:33 -0500 Subject: [PATCH 6/7] pushing v.1 of copilot chat cleaned and ready to go --- src/app/App.tsx | 21 +------- src/app/components/Copilot.tsx | 93 ++++++++++---------------------- src/app/components/Header.tsx | 21 ++------ src/app/components/Indicator.tsx | 12 ----- src/app/components/TextInput.tsx | 2 - 5 files changed, 34 insertions(+), 115 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index d64470a..219ab7b 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -84,18 +84,6 @@ export function App(): React.JSX.Element { display: "flex", boxShadow: '-4px 4px 5px rgba(0,0,0, 0.2)', }}> - {/* (6) here we can take this div and give it a min height, so that even when the list is not filled with snippets, - the refresh button is still located towards the bottom of the column. - - to match our new header, we can go ahead and start adjusting some of the colors here before moving on to - make some final tweaks to the components. - - a. added a background color to the main view container directly one level above ^^ - b. add the light shadow to the container as well from the component file for the /component/header.tsx - c. add some minimal padding to the container for you snippets that contains the array.map - d. add a title to this side - 'Saved Snippets' from your design - - */}
@@ -155,9 +143,6 @@ export function App(): React.JSX.Element {
))} - - {/* (5) @jordan-pieces moved the refresh button up higher in this file and placed it inside the same column div as the snippet list. - this will create the more side by side look that you were going for in your design example */}
@@ -169,7 +154,7 @@ export function App(): React.JSX.Element { - {/* this is the copilot container. the copilot logic is inside of /components/Copilot.tsx */} + {/* this is the copilot container. the copilot logic is inside the /components/Copilot.tsx */}
{ // conditional for the response back on application. // - // (1) first @jordan-pieces came in here and added this turing statement here inside a new - // if statement. this is an upgrade in comparison to the previous if statement that would not check to - // see if the _indicator itself is added to the DOM yet. + // TODO: add some better error handling components and log - abstract the connect to its own file as well. if (_indicator != null) { __ != undefined ? _indicator.style.backgroundColor = "green" : _indicator.style.backgroundColor = "red"; } diff --git a/src/app/components/Copilot.tsx b/src/app/components/Copilot.tsx index 039b1f7..00c7df1 100644 --- a/src/app/components/Copilot.tsx +++ b/src/app/components/Copilot.tsx @@ -9,36 +9,13 @@ import CopilotStreamController from '../controllers/copilotStreamController'; let GlobalConversationID: string; -// let GlobalConversationAnswers: Array = []; - -// this gets all conversations that are currently available for a user. there is an optional param that you can set to -// also return the first asset in the list. -// TODO: any is baaaad here but coming back to clean up. output is either void or the first conversation in the list. - -// export function refreshCopilot(): void { -// getAllConversations(true) -// } -// -// -// export function getAllConversations(firstOnly?: boolean): any { -// new Pieces.ConversationsApi().conversationsSnapshot({}).then(output => { -// // exit here quickly if the first flag is not set. -// if (firstOnly == false){ -// console.log(output) -// } else { -// return output.iterable.at(0); -// } -// }) -// } // going to use get all conversations with a few extra steps to store the current conversations locally. - export function createNewConversation() { + // logs --> CREATING CONVERSATION console.log('Begin creating conversation...') - // variables for storing our values as they come in. - // let iterable: Array; // to create a new conversation, you need to first pass in a seeded conversation in the request body. // the only mandatory parameter is the ConversationTypeEnum.Copilot value. @@ -58,53 +35,40 @@ export function createNewConversation() { if (_c.raw.ok == true && _c.raw.status == 200) { console.log('CLEAN RESPONSE BACK.') _c.value().then(_conversation => { - console.log('Returning new conversation values:') - console.log('ID | ' + _conversation.id); + console.log('Returning new conversation values.'); + // console.log('ID | ' + _conversation.id); + // console.log('NAME | ' + _conversation.name); + // console.log('CREATED | ' + _conversation.created.readable); + // console.log('ID: ' + _conversation.); - // we also are going to set the current conversation id globally here: + // Set the conversation variable here for the local file: GlobalConversationID = _conversation.id; - - console.log('NAME | ' + _conversation.name); - console.log('CREATED | ' + _conversation.created.readable); - // console.log('ID: ' + _conversation.); }) } }) } - -function sendConversationMessage(prompt: string, conversationID: string = GlobalConversationID){ - // 1. seed a message - // 2. get the conversation id from somewhere - likely the createNewConversation above ^^ - // 3. send the new message over - // 4. use the message contents in the (not yet created) message stream/list - console.log(prompt); - console.log(conversationID); - - askQuestion({query: prompt, relevant: ''}).then((r) => { - return r.result; - }).then((value) => { - // TODO: need to collect all of the iterable answers here for the viewer. - // TODO: i believe this is near the implementation of the stream. - let _answers = value.answers.iterable; - // console.log(_answers); - - // let's store the new answers globally for this file: - // GlobalConversationAnswers = [..._answers]; - }) -} - -function ChatsComponent() { - - // return
- // {GlobalConversationAnswers.map((item: Pieces.QGPTQuestionAnswer, index) => ( - //
- // ))}; - //
-} +// You can use this here to set and send a conversation message. +// function sendConversationMessage(prompt: string, conversationID: string = GlobalConversationID){ +// // 1. seed a message +// // 2. get the conversation id from somewhere - likely the createNewConversation above ^^ +// // 3. send the new message over +// // 4. use the message contents in the (not yet created) message stream/list +// // console.log(prompt); +// // console.log(conversationID); +// +// askQuestion({query: prompt, relevant: ''}).then((r) => { +// return r.result; +// }).then((value) => { +// +// // TODO: need to collect all of the iterable answers here for the viewer. +// // TODO: i believe this is near the implementation of the stream. +// // let _answers = value.answers.iterable; +// // let's store the new answers globally for this file: +// // GlobalConversationAnswers = [..._answers]; +// }) +// } export async function askQuestion({ query, @@ -150,7 +114,6 @@ export function CopilotChat(): React.JSX.Element { // handles the data changes on the chat input. const handleCopilotChatInputChange = (event: { target: { value: React.SetStateAction; }; }) => { setData(event.target.value); - // console.log(event.target.value) }; // for setting the initial copilot chat that takes place on page load. @@ -173,8 +136,10 @@ export function CopilotChat(): React.JSX.Element {

Copilot Chat

+ {/* TODO: configure for selecting a conversation */} {/**/} +