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 diff --git a/package.json b/package.json index ca9388d..e48a888 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "testing-sdk", - "version": "1.0.0", + "name": "pieces-os-client-example-ts", + "version": "1.1.0", "main": "src/index.tsx", "scripts": { "dev": "ts-node src/index.tsx", @@ -9,7 +9,7 @@ "build": "react-scripts build" }, "dependencies": { - "@pieces.app/pieces-os-client": "^1.0.0", + "@pieces.app/pieces-os-client": "1.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-scripts": "^5.0.1", diff --git a/src/app/App.tsx b/src/app/App.tsx index 0ca4ba3..130e6af 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,7 +4,9 @@ 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' +import CopilotStreamController from "./controllers/copilotStreamController"; // types type LocalAsset = { @@ -15,7 +17,7 @@ type LocalAsset = { //=============================[GLOBALS]================================// let full_context: JSON; -let applicationData: Application; +export var applicationData: Application; let _indicator: HTMLElement; let snippetList: Array; @@ -36,6 +38,7 @@ export function App(): React.JSX.Element { useEffect(() => { refreshSnippetList(); + CopilotStreamController.getInstance(); }, []); const clearArray = () => { @@ -81,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 - - */}
@@ -152,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 */}
@@ -166,6 +154,22 @@ export function App(): React.JSX.Element { + {/* this is the copilot container. the copilot logic is inside the /components/Copilot.tsx */} +
+ +
+ ) @@ -183,15 +187,13 @@ connect().then(__ => { _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. + // 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"; } - //agrim implemented - Upon connecting to the Pieces OS, there is a need to enhance the user experience by implementing a timer - //that automatically hides the "You're Connected" text and shrinks the button after a certain duration + + // @agrim implemented - Upon connecting to the Pieces OS, there is a need to enhance the user experience by implementing a timer + // that automatically hides the "You're Connected" text and shrinks the button after a certain duration let time = 3000; setTimeout(() => { if (_indicator != null) { diff --git a/src/app/components/Copilot.tsx b/src/app/components/Copilot.tsx new file mode 100644 index 0000000..00c7df1 --- /dev/null +++ b/src/app/components/Copilot.tsx @@ -0,0 +1,198 @@ +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"; +import CopilotStreamController from '../controllers/copilotStreamController'; + + +let GlobalConversationID: string; + + +// 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...') + + // 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); + // console.log('NAME | ' + _conversation.name); + // console.log('CREATED | ' + _conversation.created.readable); + // console.log('ID: ' + _conversation.); + + // Set the conversation variable here for the local file: + GlobalConversationID = _conversation.id; + }) + } + }) +} + + +// 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, + 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(''); + const [message, setMessage] = useState(''); + + // handles the data changes on the chat input. + const handleCopilotChatInputChange = (event: { target: { value: React.SetStateAction; }; }) => { + setData(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. */} +
+
+

{message}

+
+
+ +
+
+ ) +} diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx index 72dc109..6608df3 100644 --- a/src/app/components/Header.tsx +++ b/src/app/components/Header.tsx @@ -1,25 +1,10 @@ import * as React from 'react' import { Indicator } from "./Indicator" -// (3) Here you can see some of the html structure that you were writing in the index.html file. Inside the -// return ( -// -// ) -// you can see that this acts like a html file and can be used as a location to -// write html or reference other React.JSX.Element(s). So we can start by adding your styling here for the header. -// -// a. you did not include any styling for the header for the \website\wave.png image that you added. so instead I am -// going to remove the headerImg for now and instead change the background color of the Header element itself. -// b. going to add some styling to the header text to change the color, so we can see it, and add some horizontal -// padding to the header container. -// c. we will top it off by adding some rounding and shadow to the header container. -// d. while we are here i would love to add some margin below the header as well to let it breathe. -// e. added a few styles here to the text and a little tagline. -// -// Now lets head over to the component to get the text in there that you wanted in /components/Indicator.tsx -// -// this is the header element with its children: + +// Header element with connection indicator nested inside, shows if pieces os is running. +// We don't support logic for detecting the offline status but will be adding it soon! export function Header(): React.JSX.Element { return (
diff --git a/src/app/components/Indicator.tsx b/src/app/components/Indicator.tsx index 4331db5..543ab34 100644 --- a/src/app/components/Indicator.tsx +++ b/src/app/components/Indicator.tsx @@ -5,18 +5,6 @@ import check from "../icons/check.png"; // this is your indicator badge that we will manipulate through the initial connect call. it will either // be green or red depending on the current status. - -// (4) So in your designs I saw that you wanted to add the 'text connected and secured' and a check to the circle indicator. -// this should be pretty simple to add in with a few steps: -// -// a. First lets get the icon for the check mark - I got one from icons 8 here: https://icons8.com/icon/set/check/color -// b. Now lets move the text inside the pill here that you suggested -// c. then add the element after the you just added, styled it, and TODO: I added a ts-ignore above the image import (cant get it to go away) -// d. added an id to the image for later and we are good to go for now. -// -// lets head over to the App.tsx file at /app/App.tsx and we can get going on rearranging some pieces and parts of this -// landing index page. - export function Indicator(): React.JSX.Element { return ( <> diff --git a/src/app/components/TextInput.tsx b/src/app/components/TextInput.tsx index 8c72d90..d3c46a0 100644 --- a/src/app/components/TextInput.tsx +++ b/src/app/components/TextInput.tsx @@ -25,8 +25,6 @@ export function DataTextInput({applicationData}) { ); } -// (7) now we are bringing selected index into the button here and using it to determine the visibility of the button -// and will now show when selected. export function DeleteAssetButton({assetID, selectedIndex}) { return ( <> diff --git a/src/app/controllers/copilotStreamController.tsx b/src/app/controllers/copilotStreamController.tsx new file mode 100644 index 0000000..c001dc0 --- /dev/null +++ b/src/app/controllers/copilotStreamController.tsx @@ -0,0 +1,200 @@ + +import * as Pieces from "@pieces.app/pieces-os-client"; + +export type MessageOutput = { + answer: 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 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; + + // 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, + setMessage + }: { + query: string; + setMessage: (message: string) => void; + }): Promise { + if (!this.ws) { + this.connect(); + } // need to connect the socket if it's not established. + await fetch(`http://localhost:1000/.well-known/health`).catch(() => { + // @TODO add error handling here + }); + + // @TODO add conversation id + const input: Pieces.QGPTStreamInput = { + question: { + query, + relevant: {iterable: []} //@TODO hook up /relevance here for context + }, + }; + + return this.handleMessages({ input, setMessage }); + } + + /** + * Connects the websocket, handles all message callbacks, error handling, and rendering. + */ + private connect() { + this.ws = new WebSocket(`ws://localhost:1000/qgpt/stream`); + + let totalMessage = ''; + let relevantSnippets: Pieces.RelevantQGPTSeed[] = []; + + this.ws.onmessage = (msg) => { + const json = JSON.parse(msg.data); + const result = Pieces.QGPTStreamOutputFromJSON(json); + let answer: Pieces.QGPTQuestionAnswer | undefined; + let relevant: Pieces.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') { + // add the buttons to the answer element's code blocks. + if (!totalMessage) { + this.setMessage("I'm sorry, it seems I don't have any relevant context to that question. Please try again 😃") + } + + // 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 = ''; + relevantSnippets = []; + } + return; + } + // add to the total message + if (answer?.text) { + totalMessage += answer.text; + } + // render the new total message + this.handleRender(totalMessage); + }; + + 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, + setMessage, + }: { + input: Pieces.QGPTStreamInput; + setMessage: (message: string) => void; + }) { + if (!this.ws) this.connect(); + await this.connectionPromise; + this.setMessage = setMessage; + + // init message promise + const promise = new Promise((res, rej) => { + this.messageResolver = res; + this.messageRejector = rej; + }); + + try { + 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, + ) { + // 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() { + return (CopilotStreamController.instance ??= new CopilotStreamController()); + } +}