diff options
author | Nathan-SR <144961007+Nathan-SR@users.noreply.github.com> | 2024-09-08 17:44:11 -0400 |
---|---|---|
committer | Nathan-SR <144961007+Nathan-SR@users.noreply.github.com> | 2024-09-08 17:44:11 -0400 |
commit | 984a470094399e4bbd74ddb7daa3e6f08a0a4793 (patch) | |
tree | 40a1aea127c4f262d4b0d34b71ece127764c7a10 /src | |
parent | d4c3af196d8fc7355a9b78b78f17e4b44bd4f62b (diff) |
image generation working!
Diffstat (limited to 'src')
-rw-r--r-- | src/client/apis/gpt/GPT.ts | 6 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/DataVizBox.tsx | 15 | ||||
-rw-r--r-- | src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx | 193 |
3 files changed, 142 insertions, 72 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index f1d5ab533..ec3a349fc 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -15,7 +15,8 @@ enum GPTCallType { TEMPLATE = "template", VIZSUM = 'vizsum', VIZSUM2 = 'vizsum2', - FILL = 'fill' + FILL = 'fill', + COMPLETEPROMPT = 'completeprompt' } type GPTCallOpts = { @@ -60,7 +61,8 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { template: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'You will be given a list of field descriptions for multiple templates in the format {field #0: “description”}{field #1: “description”}{...}, and a list of column descriptions in the format {“title”: “description”}{...}. Your job is to match columns with fields based on their descriptions. Your output should be in the following JSON format: {“Template title”:{“#”: “title”, “#”: “title”, “#”: “title” …}, “Template title”:{“#”: “title”, “#”: “title”, “#”: “title” …}} where “Template title” represents the template, # represents the field # and “title” the title of the column assigned to it. A filled out example might look like {“fivefield2”:{“0”:”Name”, “1”:”Image”, “2”:”Caption”, “3”:”Position”, “4”:”Stats”}, “fivefield3”:{0:”Image”, 1:”Name”, 2:”Caption”, 3:”Stats”, 4:”Position”}. Include one object for each template. IT IS VERY IMPORTANT THAT YOU ONLY INCLUDE TEXT IN THE FORMAT ABOVE, WITH NO ADDITIONS WHATSOEVER. Do not include extraneous ‘#’ characters, ‘column’ for columns, or ‘template’ for templates: ONLY THE TITLES AND NUMBERS. There should never be one column assigned to more than one field (ie. if the “name” column is assigned to field 1, it can’t be assigned to any other fields) . Do this for each template whose fields are described. The descriptions are as follows:' }, vizsum: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Your job is to provide brief descriptions for columns in a dataset based on example rows. Your descriptions should be geared towards how each column’s data might fit together into a visual template. Would they make good titles, main focuses, captions, descriptions, etc. Pay special attention to connections between columns, i.e. is there one column that specifically seems to describe/be related to another more than the rest? You should provide your analysis in JSON format like so: {“col1”:”description”, “col2”:”description”, …}. DO NOT INCLUDE ANY OTHER TEXT, ONLY THE JSON.'}, vizsum2: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Your job is to provide structured information on columns in a dataset based on example rows. You will categorize each column in two ways: by type and size. The size categories are as follows: tiny (one or two words), small (a sentence/multiple words), medium (a few sentences), large (a longer paragraph), and huge (a very long or multiple paragraphs). The type categories are as follows: visual (links/file paths to images, pdfs, maps, or any other visual media type), and text (plain text that isn’t a link/file path). Visual media should be assumed to have size “medium” “large” or “huge”. You will give your responses in JSON format, like so: {“title (of column)”:{“type”:”text”, “size”:”small”}, “title (of column)”:{“type”:”visual”, “size”:”medium”}, …}. DO NOT INCLUDE ANY OTHER TEXT, ONLY THE JSON.'}, - fill: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Your job is to generate content for fields based on a user prompt and background context given to you. You will be given the content of the other fields present in the format: ---- Field # (field title): content ---- Field # (field title): content ----- (etc.) You will be given info on the columns to generate for in the format ---- title: , prompt: , word limit: , assigned field: ----. For each column, based on the prompt, word limit, and the context of existing fields, you should generate a short response in the following JSON format: {“___”(where ___ is the title from the column description with no additions): {“number”:”#” (where # is the assigned field of the column), “content”:”response” (where response is your response to the prompt in the column info)}}. Here’s another example of the format with only one column: {“position”: {“number”:”2”, “content”:”*your response goes here*”}}. ONLY INCLUDE THE JSON TEXT WITH NO OTHER ADDED TEXT. YOUR RESPONSE MUST BE VALID JSON. The word limit for each column applies only to that column’s response. Do not include speculation or information that you can’t glean from your factual knowledge or the content of the other fields (no description of images you can’t see, for example). You should include one object per column you are provided info on.'} + fill: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Your job is to generate content for fields based on a user prompt and background context given to you. You will be given the content of the other fields present in the format: ---- Field # (field title): content ---- Field # (field title): content ----- (etc.) You will be given info on the columns to generate for in the format ---- title: , prompt: , word limit: , assigned field: ----. For each column, based on the prompt, word limit, and the context of existing fields, you should generate a short response in the following JSON format: {“___”(where ___ is the title from the column description with no additions): {“number”:”#” (where # is the assigned field of the column), “content”:”response” (where response is your response to the prompt in the column info)}}. Here’s another example of the format with only one column: {“position”: {“number”:”2”, “content”:”*your response goes here*”}}. ONLY INCLUDE THE JSON TEXT WITH NO OTHER ADDED TEXT. YOUR RESPONSE MUST BE VALID JSON. The word limit for each column applies only to that column’s response. Do not include speculation or information that you can’t glean from your factual knowledge or the content of the other fields (no description of images you can’t see, for example). You should include one object per column you are provided info on.'}, + completeprompt: {model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Your prompt is as follows:'}, }; let lastCall = ''; let lastResp = ''; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 0efe8ead0..0e3b602d6 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -157,13 +157,11 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @action setColumnType = (colTitle: string, type: TemplateFieldType) => { const colInfo = this.colsInfo.get(colTitle); - console.log(colInfo) if (colInfo) { colInfo.type = type; } else { this.colsInfo.set(colTitle, {title: colTitle, desc: '', type: type, sizes: [TemplateFieldSize.MEDIUM]}) } - console.log(colInfo?.title, colInfo?.type) } @action modifyColumnSizes = (colTitle: string, size: TemplateFieldSize, valid: boolean) => { @@ -183,8 +181,9 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const colInfo = this.colsInfo.get(colTitle); if (colInfo) { colInfo.title = newTitle; + console.log(colInfo.title) } else { - this.colsInfo.set(colTitle, {title: newTitle, desc: '', type: TemplateFieldType.UNSET, sizes: [TemplateFieldSize.MEDIUM]}) + this.colsInfo.set(colTitle, {title: newTitle, desc: '', type: TemplateFieldType.UNSET, sizes: []}) } } @@ -194,7 +193,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { if (!desc) { colInfo.desc = this.GPTSummary?.get(colTitle)?.desc ?? ''; } else { colInfo.desc = desc; } } else { - this.colsInfo.set(colTitle, {title: colTitle, desc: desc, type: TemplateFieldType.UNSET, sizes: [TemplateFieldSize.MEDIUM]}) + this.colsInfo.set(colTitle, {title: colTitle, desc: desc, type: TemplateFieldType.UNSET, sizes: []}) } } @@ -203,7 +202,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { if (colInfo) { colInfo.defaultContent = cont; } else { - this.colsInfo.set(colTitle, {title: colTitle, desc: '', type: TemplateFieldType.UNSET, sizes: [TemplateFieldSize.MEDIUM], defaultContent: cont}) + this.colsInfo.set(colTitle, {title: colTitle, desc: '', type: TemplateFieldType.UNSET, sizes: [], defaultContent: cont}) } } @@ -593,11 +592,9 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const prompt = this.getColSummary(); - console.log(prompt) - const cols = Array.from(Object.keys(this.records[0])).filter(header => header !== '' && header !== undefined); cols.forEach(col => { - if (!this.colsInfo.get(col)) this.colsInfo.set(col, {title: col, desc: '', sizes: [TemplateFieldSize.MEDIUM], type: TemplateFieldType.UNSET}); + if (!this.colsInfo.get(col)) this.colsInfo.set(col, {title: col, desc: '', sizes: [], type: TemplateFieldType.UNSET}); }); try { @@ -607,7 +604,6 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { ]); if (res1) { - console.log(res1); this.GPTSummary = new ObservableMap(); const descs: { [col: string]: string } = JSON.parse(res1); for (const [key, val] of Object.entries(descs)) { @@ -619,7 +615,6 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { if (res2) { !this.GPTSummary && (this.GPTSummary = new ObservableMap()); const info: { [col: string]: { type: TemplateFieldType, size: TemplateFieldSize } } = JSON.parse(res2); - console.log(info); for (const [key, val] of Object.entries(info)) { const colSummary = this.GPTSummary.get(key); if (colSummary) { diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx index 8f7bf8713..dcdd52c73 100644 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu.tsx @@ -2,7 +2,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { returnAll, returnFalse, returnNone, returnOne, returnZero, setupMoveUpEvents } from '../../../../ClientUtils'; +import { ClientUtils, returnAll, returnFalse, returnNone, returnOne, returnZero, setupMoveUpEvents } from '../../../../ClientUtils'; import { Doc, NumListCast, StrListCast } from '../../../../fields/Doc'; import { DocCast, ImageCast, ScriptCast, StrCast } from '../../../../fields/Types'; import { ImageField } from '../../../../fields/URLField'; @@ -17,7 +17,7 @@ import { Id } from '../../../../fields/FieldSymbols'; import { Colors, IconButton, Size } from 'browndash-components'; import { MakeTemplate } from '../../../util/DropConverter'; import { DragManager } from '../../../util/DragManager'; -import { GPTCallType, gptAPICall } from '../../../apis/gpt/GPT'; +import { GPTCallType, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT'; import { CollectionFreeFormView } from '../../collections/collectionFreeForm/CollectionFreeFormView'; import { Docs } from '../../../documents/Documents'; import { OpenWhere } from '../OpenWhere'; @@ -32,6 +32,7 @@ import { ImageBox } from '../ImageBox'; import { a } from '@react-spring/web'; import { RichTextMenu } from '../formattedText/RichTextMenu'; import e from 'cors'; +import { Networking } from '../../../Network'; export enum LayoutType { Stacked = 'stacked', @@ -423,7 +424,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { - testTemplate = () => { + testTemplate = async() => { // const temp = TemplateLayouts.FourField001; // const title: Doc = FieldFuncs.TextField({tl: temp.fields[0].tl, br: temp.fields[0].br}, temp.height, temp.width, 'title', 'Title', {backgroundColor: 'transparent'}); // const image: Doc = FieldFuncs.ImageField({tl: temp.fields[1].tl, br: temp.fields[1].br}, temp.height, temp.width, 'title', '', {borderColor: '#159fe4', borderWidth: '10', cornerRounding: 10, rotation: 40}); @@ -450,11 +451,22 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { // console.log(this._dataViz?.colsInfo.get("IMG")?.size, this._dataViz?.colsInfo.get("IMG")?.type) // console.log(this.fieldsInfos) + try { + const res = await gptImageCall('Image of panda eating a cookie'); + + if (res){ + const result = await Networking.PostToServer('/uploadRemoteImage', { sources: res }); + + console.log(result); + } + } catch (e) { + console.log(e); + } }; @action addField = () => { - const newFields: Col[] = this._columns.concat([{title: '', type: TemplateFieldType.UNSET, desc: '', sizes: [TemplateFieldSize.MEDIUM]}]) + const newFields: Col[] = this._columns.concat([{title: '', type: TemplateFieldType.UNSET, desc: '', sizes: []}]) this._columns = newFields; }; @@ -479,7 +491,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { } }; - setColTitle = (column: Col, title: string) => { + @action setColTitle = (column: Col, title: string) => { if (this.selectedFields.includes(column.title)) { this._dataViz?.setColumnTitle(column.title, title); } else { @@ -488,7 +500,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { this.forceUpdate(); }; - setColType = (column: Col, type: TemplateFieldType) => { + @action setColType = (column: Col, type: TemplateFieldType) => { if (this.selectedFields.includes(column.title)) { this._dataViz?.setColumnType(column.title, type); } else { @@ -507,7 +519,6 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { column.sizes.push(size); } } - console.log(column.sizes) this.forceUpdate(); }; @@ -520,6 +531,22 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { this.forceUpdate(); }; + generateGPTImage = async(prompt: string): Promise<string | undefined> => { + console.log(prompt) + + try { + const res = await gptImageCall(prompt); + + if (res){ + const result = await Networking.PostToServer('/uploadRemoteImage', { sources: res }); + const source = ClientUtils.prepend(result[0].accessPaths.agnostic.client); + return source; + } + } catch (e) { + console.log(e); + } + } + matchesForTemplate = (template: TemplateDocInfos, cols: Col[]): number[][] => { const colMatchesField = (col: Col, field : Field) => { return field.sizes?.some(size => col.sizes?.includes(size)) && field.types?.includes(col.type) }; @@ -539,13 +566,11 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { maxMatches = (fieldsCt: number, matches: number[][]) => { const used: boolean[] = Array(fieldsCt).fill(false); const mt: number[] = Array(fieldsCt).fill(-1); - console.log(fieldsCt, matches) const augmentingPath = (v: number): boolean => { if (used[v]) return false; used[v] = true; for (const to of matches[v]) { - console.log(mt[to]); if (mt[to] === -1 || augmentingPath(mt[to])) { mt[to] = v; return true; @@ -582,7 +607,6 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { validTemplates = validTemplates.map(title => TemplateLayouts.fieldByTitle(title)); - console.log(validTemplates); return validTemplates; }; @@ -626,20 +650,105 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { } } + const renderTextCalls = async(): Promise<Doc[]> => { + const rendered: Doc[] = []; + + if (GPTTextCalls.length) { + + try { + const prompt = fieldContent + GPTTextAssignment; + + const res = await gptAPICall(prompt, GPTCallType.FILL); + + if (res){ + + const assignments: {[title: string]: {number: string, content: string}} = JSON.parse(res); + //console.log('assignments', GPTAssignments, 'assignment string', GPTAssignmentString, 'field content', fieldContent, 'response', res, 'assignments', assignments); + Object.entries(assignments).forEach(([title, info]) => { + const field: Field = template.fields[Number(info.number)]; + const col = this.getColByTitle(title); + + const doc = FieldFuncs.TextField({ + tl: field.tl, + br: field.br }, + template.height, + template.width, + col.title, + info.content ?? '', + field.opts + ); + + rendered.push(doc); + }); + + } + } catch(err) { + console.log(err); + } + } + + return rendered; + }; + + const createGeneratedImage = async(fieldNum: string, col: Col, prompt: string) => { + const url = await this.generateGPTImage(prompt); + const field: Field = template.fields[Number(fieldNum)]; + const doc = FieldFuncs.ImageField({ + tl: field.tl, + br: field.br }, + template.height, + template.width, + col.title, + url ?? '', + field.opts + ); + + return doc; + } + + const renderImageCalls = async(): Promise<Doc[]> => { + const rendered: Doc[] = []; + const calls = GPTIMGCalls; + + if (calls.length) { + try { + const renderedImages: Doc[] = await Promise.all( + calls.map(async ([fieldNum, col]) => { + const sysPrompt = 'Your job is to create a prompt for an AI image generator to help it generate an image based on existing content in a template and a user prompt. ONLY INCLUDE THE PROMPT, NO OTHER TEXT OR EXPLANATION. The existing content is as follows: ' + fieldContent + ' **** The user prompt is: ' + col.desc; + + const prompt = await gptAPICall(sysPrompt, GPTCallType.COMPLETEPROMPT); + console.log(sysPrompt, prompt); + + return createGeneratedImage(fieldNum, col, prompt); + }) + ); + + const renderedTemplates: Doc[] = await Promise.all(renderedImages); + renderedTemplates.forEach(doc => rendered.push(doc)); + } catch (e){ + console.log(e); + } + } + + return rendered; + } + const fields: Doc[] = []; const GPTAssignments = Object.entries(assignments).filter(([f, col]) => this._columns.includes(col)); const nonGPTAssignments: [string, Col][] = Object.entries(assignments).filter(a => !GPTAssignments.includes(a)); + const GPTTextCalls = GPTAssignments.filter(([str, col]) => col.type === TemplateFieldType.TEXT); + const GPTIMGCalls = GPTAssignments.filter(([str, col]) => col.type === TemplateFieldType.VISUAL); - const stringifyGPTInfo = (): string => { + const stringifyGPTInfo = (calls: [string, Col][]): string => { let string: string = '*** COLUMN INFO:'; - GPTAssignments.forEach(([fieldNum, col]) => { + calls.forEach(([fieldNum, col]) => { string += `--- title: ${col.title}, prompt: ${col.desc}, word limit: ${wordLimit(col.sizes[0])} words, assigned field: ${fieldNum} ---` }); return string += ' ***'; }; - const GPTAssignmentString = stringifyGPTInfo(); + const GPTTextAssignment = stringifyGPTInfo(GPTTextCalls); let fieldContent: string = ''; @@ -695,47 +804,14 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { return main; } - if (GPTAssignments.length) { - - try { - const prompt = fieldContent + GPTAssignmentString; - - const res = await gptAPICall(prompt, GPTCallType.FILL); - - if (res){ - console.log('response', res); - - const assignments: {[title: string]: {number: string, content: string}} = JSON.parse(res); - console.log('assignments', GPTAssignments, 'assignment string', GPTAssignmentString, 'field content', fieldContent, 'response', res, 'assignments', assignments); - Object.entries(assignments).forEach(([title, info]) => { - const field: Field = template.fields[Number(info.number)]; - const col = this.getColByTitle(title); - - const doc = (col.type === TemplateFieldType.VISUAL ? FieldFuncs.ImageField : FieldFuncs.TextField)({ - tl: field.tl, - br: field.br }, - template.height, - template.width, - col.title, - info.content ?? '', - field.opts - ); - - fields.push(doc); - }); - - return createMainDoc(); - } - } catch(err) { - console.log(err); - } - - } else { - return createMainDoc(); - } + const textCalls = await renderTextCalls(); + const imageCalls = await renderImageCalls(); - return new Doc; - }; + textCalls.forEach(doc => {fields.push(doc)}); + imageCalls.forEach(doc => {fields.push(doc)}); + + return createMainDoc(); + } compileFieldDescriptions = (templates: TemplateDocInfos[]): string => { let descriptions: string = ''; @@ -765,7 +841,6 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { const inputText = fieldDescriptions.concat(colDescriptions); - console.log(inputText); ++this._callCount; const origCount = this._callCount; @@ -779,14 +854,12 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { if (res && this._callCount === origCount) { this._GPTLoading = false; - console.log(res); - const assignments: {[templateTitle: string]: {[field: string]: string}} = JSON.parse(res); const brokenDownAssignments: [TemplateDocInfos, {[field: number]: Col}][] = []; + Object.entries(assignments).forEach(([tempTitle, assignment]) => { const template = TemplateLayouts.fieldByTitle(tempTitle); if (!template) return; - console.log(assignments) const toObj = Object.entries(assignment).reduce((a, [fieldNum, colTitle]) => { a[Number(fieldNum)] = this.getColByTitle(colTitle); return a; @@ -849,7 +922,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { <div className='docCreatorMenu-section' style={{height: this._GPTOpt ? 200 : 200}}> <div className='docCreatorMenu-section-topbar'> <div className='docCreatorMenu-section-title'>Suggested Templates</div> - <button className='docCreatorMenu-menu-button section-reveal-options' onPointerDown={e => this.setUpButtonClick(e, () => this._menuContent = 'dashboard')}> + <button className='docCreatorMenu-menu-button section-reveal-options' onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this._menuContent = 'dashboard'))}> <FontAwesomeIcon icon='gear'/> </button> </div> @@ -1197,7 +1270,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { <div className='opts-bar'> <div className='opt-box'> <div className='top-bar'> Title </div> - <textarea className='content' style={{width: '100%', height: 'calc(100% - 20px)'}} defaultValue={field.title} placeholder={'Enter title'} onChange={(e) => this.setColTitle(field, e.target.value)}/> + <textarea className='content' style={{width: '100%', height: 'calc(100% - 20px)'}} defaultValue={field.title} placeholder={'Enter title'} onChange={(e) => this.setColTitle(field, e.target.value)}/> </div> <div className='opt-box'> <div className='top-bar'> Type </div> @@ -1237,7 +1310,7 @@ export class DocCreatorMenu extends ObservableReactComponent<FieldViewProps> { <button className='docCreatorMenu-menu-button section-reveal-options' onPointerDown={e => this.setUpButtonClick(e, this.addField)}> <FontAwesomeIcon icon='plus'/> </button> - <button className='docCreatorMenu-menu-button section-reveal-options float-right' onPointerDown={e => this.setUpButtonClick(e, () => this._menuContent = 'templates')}> + <button className='docCreatorMenu-menu-button section-reveal-options float-right' onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this._menuContent = 'templates'))}> <FontAwesomeIcon icon='arrow-left'/> </button> </div> |