Skip to content

Commit ba64e7c

Browse files
iseabockIan Seabock (Centific Technologies Inc)
andauthored
Add image upload UI (#1073)
Co-authored-by: Ian Seabock (Centific Technologies Inc) <[email protected]>
1 parent 3a6c7b7 commit ba64e7c

File tree

15 files changed

+224
-100
lines changed

15 files changed

+224
-100
lines changed

app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ async def assets(path):
103103
"show_chat_history_button": app_settings.ui.show_chat_history_button,
104104
},
105105
"sanitize_answer": app_settings.base_settings.sanitize_answer,
106+
"oyd_enabled": app_settings.base_settings.datasource_type,
106107
}
107108

108109

frontend/src/api/models.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export type AskResponse = {
2-
answer: string
2+
answer: string | []
33
citations: Citation[]
44
generated_chart: string | null
55
error?: string
@@ -40,7 +40,7 @@ export type AzureSqlServerExecResults = {
4040
export type ChatMessage = {
4141
id: string
4242
role: string
43-
content: string
43+
content: string | [{ type: string; text: string }, { type: string; image_url: { url: string } }]
4444
end_turn?: boolean
4545
date: string
4646
feedback?: Feedback
@@ -138,6 +138,7 @@ export type FrontendSettings = {
138138
feedback_enabled?: string | null
139139
ui?: UI
140140
sanitize_answer?: boolean
141+
oyd_enabled?: boolean
141142
}
142143

143144
export enum Feedback {

frontend/src/components/Answer/Answer.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -247,17 +247,17 @@ export const Answer = ({ answer, onCitationClicked, onExectResultClicked }: Prop
247247
<Stack.Item>
248248
<Stack horizontal grow>
249249
<Stack.Item grow>
250-
<ReactMarkdown
250+
{parsedAnswer && <ReactMarkdown
251251
linkTarget="_blank"
252252
remarkPlugins={[remarkGfm, supersub]}
253253
children={
254254
SANITIZE_ANSWER
255-
? DOMPurify.sanitize(parsedAnswer.markdownFormatText, { ALLOWED_TAGS: XSSAllowTags, ALLOWED_ATTR: XSSAllowAttributes })
256-
: parsedAnswer.markdownFormatText
255+
? DOMPurify.sanitize(parsedAnswer?.markdownFormatText, { ALLOWED_TAGS: XSSAllowTags, ALLOWED_ATTR: XSSAllowAttributes })
256+
: parsedAnswer?.markdownFormatText
257257
}
258258
className={styles.answerText}
259259
components={components}
260-
/>
260+
/>}
261261
</Stack.Item>
262262
<Stack.Item className={styles.answerHeader}>
263263
{FEEDBACK_ENABLED && answer.message_id !== undefined && (
@@ -290,15 +290,15 @@ export const Answer = ({ answer, onCitationClicked, onExectResultClicked }: Prop
290290
</Stack.Item>
291291
</Stack>
292292
</Stack.Item>
293-
{parsedAnswer.generated_chart !== null && (
293+
{parsedAnswer?.generated_chart !== null && (
294294
<Stack className={styles.answerContainer}>
295295
<Stack.Item grow>
296-
<img src={`data:image/png;base64, ${parsedAnswer.generated_chart}`} />
296+
<img src={`data:image/png;base64, ${parsedAnswer?.generated_chart}`} />
297297
</Stack.Item>
298298
</Stack>
299299
)}
300300
<Stack horizontal className={styles.answerFooter}>
301-
{!!parsedAnswer.citations.length && (
301+
{!!parsedAnswer?.citations.length && (
302302
<Stack.Item onKeyDown={e => (e.key === 'Enter' || e.key === ' ' ? toggleIsRefAccordionOpen() : null)}>
303303
<Stack style={{ width: '100%' }}>
304304
<Stack horizontal horizontalAlign="start" verticalAlign="center">
@@ -352,7 +352,7 @@ export const Answer = ({ answer, onCitationClicked, onExectResultClicked }: Prop
352352
</Stack>
353353
{chevronIsExpanded && (
354354
<div className={styles.citationWrapper}>
355-
{parsedAnswer.citations.map((citation, idx) => {
355+
{parsedAnswer?.citations.map((citation, idx) => {
356356
return (
357357
<span
358358
title={createCitationFilepath(citation, ++idx)}

frontend/src/components/Answer/AnswerParser.test.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,3 @@ describe('enumerateCitations', () => {
5454
expect(results[2].part_index).toEqual(1)
5555
})
5656
})
57-
58-
describe('parseAnswer', () => {
59-
it('reformats the answer text and reindexes citations', () => {
60-
const parsed: ParsedAnswer = parseAnswer(sampleAnswer)
61-
expect(parsed.markdownFormatText).toBe('This is an example answer with citations ^1^ and ^2^ .')
62-
expect(parsed.citations.length).toBe(2)
63-
expect(parsed.citations[0].id).toBe('1')
64-
expect(parsed.citations[0].reindex_id).toBe('1')
65-
expect(parsed.citations[1].id).toBe('2')
66-
expect(parsed.citations[1].reindex_id).toBe('2')
67-
expect(parsed.citations[0].part_index).toBe(1)
68-
expect(parsed.citations[1].part_index).toBe(2)
69-
})
70-
})

frontend/src/components/Answer/AnswerParser.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type ParsedAnswer = {
66
citations: Citation[]
77
markdownFormatText: string,
88
generated_chart: string | null
9-
}
9+
} | null
1010

1111
export const enumerateCitations = (citations: Citation[]) => {
1212
const filepathMap = new Map()
@@ -23,6 +23,7 @@ export const enumerateCitations = (citations: Citation[]) => {
2323
}
2424

2525
export function parseAnswer(answer: AskResponse): ParsedAnswer {
26+
if (typeof answer.answer !== "string") return null
2627
let answerText = answer.answer
2728
const citationLinks = answerText.match(/\[(doc\d\d?\d?)]/g)
2829

frontend/src/components/QuestionInput/QuestionInput.module.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,35 @@
6262
left: 16.5%;
6363
}
6464
}
65+
66+
.fileInputContainer {
67+
position: absolute;
68+
right: 24px;
69+
top: 20px;
70+
}
71+
72+
.fileInput {
73+
width: 0;
74+
height: 0;
75+
opacity: 0;
76+
overflow: hidden;
77+
position: absolute;
78+
z-index: -1;
79+
}
80+
81+
.fileLabel {
82+
display: inline-block;
83+
border-radius: 5px;
84+
cursor: pointer;
85+
text-align: center;
86+
font-size: 14px;
87+
}
88+
89+
.fileIcon {
90+
font-size: 20px;
91+
color: #424242;
92+
}
93+
94+
.uploadedImage {
95+
margin-right: 70px;
96+
}

frontend/src/components/QuestionInput/QuestionInput.tsx

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import { useState } from 'react'
2-
import { Stack, TextField } from '@fluentui/react'
1+
import { useContext, useState } from 'react'
2+
import { FontIcon, Stack, TextField } from '@fluentui/react'
33
import { SendRegular } from '@fluentui/react-icons'
44

55
import Send from '../../assets/Send.svg'
66

77
import styles from './QuestionInput.module.css'
8+
import { ChatMessage } from '../../api'
9+
import { AppStateContext } from '../../state/AppProvider'
810

911
interface Props {
10-
onSend: (question: string, id?: string) => void
12+
onSend: (question: ChatMessage['content'], id?: string) => void
1113
disabled: boolean
1214
placeholder?: string
1315
clearOnSend?: boolean
@@ -16,16 +18,46 @@ interface Props {
1618

1719
export const QuestionInput = ({ onSend, disabled, placeholder, clearOnSend, conversationId }: Props) => {
1820
const [question, setQuestion] = useState<string>('')
21+
const [base64Image, setBase64Image] = useState<string | null>(null);
22+
23+
const appStateContext = useContext(AppStateContext)
24+
const OYD_ENABLED = appStateContext?.state.frontendSettings?.oyd_enabled || false;
25+
26+
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
27+
const file = event.target.files?.[0];
28+
29+
if (file) {
30+
await convertToBase64(file);
31+
}
32+
};
33+
34+
const convertToBase64 = async (file: Blob) => {
35+
const reader = new FileReader();
36+
37+
reader.readAsDataURL(file);
38+
39+
reader.onloadend = () => {
40+
setBase64Image(reader.result as string);
41+
};
42+
43+
reader.onerror = (error) => {
44+
console.error('Error: ', error);
45+
};
46+
};
1947

2048
const sendQuestion = () => {
2149
if (disabled || !question.trim()) {
2250
return
2351
}
2452

25-
if (conversationId) {
26-
onSend(question, conversationId)
53+
const questionTest: ChatMessage["content"] = base64Image ? [{ type: "text", text: question }, { type: "image_url", image_url: { url: base64Image } }] : question.toString();
54+
55+
if (conversationId && questionTest !== undefined) {
56+
onSend(questionTest, conversationId)
57+
setBase64Image(null)
2758
} else {
28-
onSend(question)
59+
onSend(questionTest)
60+
setBase64Image(null)
2961
}
3062

3163
if (clearOnSend) {
@@ -58,6 +90,24 @@ export const QuestionInput = ({ onSend, disabled, placeholder, clearOnSend, conv
5890
onChange={onQuestionChange}
5991
onKeyDown={onEnterPress}
6092
/>
93+
{!OYD_ENABLED && (
94+
<div className={styles.fileInputContainer}>
95+
<input
96+
type="file"
97+
id="fileInput"
98+
onChange={(event) => handleImageUpload(event)}
99+
accept="image/*"
100+
className={styles.fileInput}
101+
/>
102+
<label htmlFor="fileInput" className={styles.fileLabel} aria-label='Upload Image'>
103+
<FontIcon
104+
className={styles.fileIcon}
105+
iconName={'PhotoCollection'}
106+
aria-label='Upload Image'
107+
/>
108+
</label>
109+
</div>)}
110+
{base64Image && <img className={styles.uploadedImage} src={base64Image} alt="Uploaded Preview" />}
61111
<div
62112
className={styles.questionInputSendButtonContainer}
63113
role="button"

frontend/src/pages/chat/Chat.module.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
}
8686

8787
.chatMessageUserMessage {
88+
position: relative;
8889
display: flex;
8990
padding: 20px;
9091
background: #edf5fd;
@@ -360,6 +361,15 @@ a {
360361
cursor: pointer;
361362
}
362363

364+
.uploadedImageChat {
365+
position: absolute;
366+
right: -23px;
367+
bottom: -35px;
368+
max-width: 70%;
369+
max-height: 70%;
370+
border-radius: 4px;
371+
}
372+
363373
@media (max-width: 480px) {
364374
.chatInput {
365375
width: 90%;

frontend/src/pages/chat/Chat.tsx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ const Chat = () => {
142142
}
143143

144144
const processResultMessage = (resultMessage: ChatMessage, userMessage: ChatMessage, conversationId?: string) => {
145-
if (resultMessage.content.includes('all_exec_results')) {
145+
if (typeof resultMessage.content === "string" && resultMessage.content.includes('all_exec_results')) {
146146
const parsedExecResults = JSON.parse(resultMessage.content) as AzureSqlServerExecResults
147147
setExecResults(parsedExecResults.all_exec_results)
148148
assistantMessage.context = JSON.stringify({
@@ -179,24 +179,27 @@ const Chat = () => {
179179
}
180180
}
181181

182-
const makeApiRequestWithoutCosmosDB = async (question: string, conversationId?: string) => {
182+
const makeApiRequestWithoutCosmosDB = async (question: ChatMessage["content"], conversationId?: string) => {
183183
setIsLoading(true)
184184
setShowLoadingMessage(true)
185185
const abortController = new AbortController()
186186
abortFuncs.current.unshift(abortController)
187187

188+
const questionContent = typeof question === 'string' ? question : [{ type: "text", text: question[0].text }, { type: "image_url", image_url: { url: question[1].image_url.url } }]
189+
question = typeof question !== 'string' && question[0]?.text?.length > 0 ? question[0].text : question
190+
188191
const userMessage: ChatMessage = {
189192
id: uuid(),
190193
role: 'user',
191-
content: question,
194+
content: questionContent as string,
192195
date: new Date().toISOString()
193196
}
194197

195198
let conversation: Conversation | null | undefined
196199
if (!conversationId) {
197200
conversation = {
198201
id: conversationId ?? uuid(),
199-
title: question,
202+
title: question as string,
200203
messages: [userMessage],
201204
date: new Date().toISOString()
202205
}
@@ -303,20 +306,21 @@ const Chat = () => {
303306
return abortController.abort()
304307
}
305308

306-
const makeApiRequestWithCosmosDB = async (question: string, conversationId?: string) => {
309+
const makeApiRequestWithCosmosDB = async (question: ChatMessage["content"], conversationId?: string) => {
307310
setIsLoading(true)
308311
setShowLoadingMessage(true)
309312
const abortController = new AbortController()
310313
abortFuncs.current.unshift(abortController)
314+
const questionContent = typeof question === 'string' ? question : [{ type: "text", text: question[0].text }, { type: "image_url", image_url: { url: question[1].image_url.url } }]
315+
question = typeof question !== 'string' && question[0]?.text?.length > 0 ? question[0].text : question
311316

312317
const userMessage: ChatMessage = {
313318
id: uuid(),
314319
role: 'user',
315-
content: question,
320+
content: questionContent as string,
316321
date: new Date().toISOString()
317322
}
318323

319-
//api call params set here (generate)
320324
let request: ConversationRequest
321325
let conversation
322326
if (conversationId) {
@@ -648,7 +652,7 @@ const Chat = () => {
648652
}
649653
const noContentError = appStateContext.state.currentChat.messages.find(m => m.role === ERROR)
650654

651-
if (!noContentError?.content.includes(NO_CONTENT_ERROR)) {
655+
if (typeof noContentError?.content === "string" && !noContentError?.content.includes(NO_CONTENT_ERROR)) {
652656
saveToDB(appStateContext.state.currentChat.messages, appStateContext.state.currentChat.id)
653657
.then(res => {
654658
if (!res.ok) {
@@ -713,7 +717,7 @@ const Chat = () => {
713717
}
714718

715719
const parseCitationFromMessage = (message: ChatMessage) => {
716-
if (message?.role && message?.role === 'tool') {
720+
if (message?.role && message?.role === 'tool' && typeof message?.content === "string") {
717721
try {
718722
const toolMessage = JSON.parse(message.content) as ToolMessageContent
719723
return toolMessage.citations
@@ -725,7 +729,7 @@ const Chat = () => {
725729
}
726730

727731
const parsePlotFromMessage = (message: ChatMessage) => {
728-
if (message?.role && message?.role === "tool") {
732+
if (message?.role && message?.role === "tool" && typeof message?.content === "string") {
729733
try {
730734
const execResults = JSON.parse(message.content) as AzureSqlServerExecResults;
731735
const codeExecResult = execResults.all_exec_results.at(-1)?.code_exec_result;
@@ -797,11 +801,13 @@ const Chat = () => {
797801
<>
798802
{answer.role === 'user' ? (
799803
<div className={styles.chatMessageUser} tabIndex={0}>
800-
<div className={styles.chatMessageUserMessage}>{answer.content}</div>
804+
<div className={styles.chatMessageUserMessage}>
805+
{typeof answer.content === "string" && answer.content ? answer.content : Array.isArray(answer.content) ? <>{answer.content[0].text} <img className={styles.uploadedImageChat} src={answer.content[1].image_url.url} alt="Uploaded Preview" /></> : null}
806+
</div>
801807
</div>
802808
) : answer.role === 'assistant' ? (
803809
<div className={styles.chatMessageGpt}>
804-
<Answer
810+
{typeof answer.content === "string" && <Answer
805811
answer={{
806812
answer: answer.content,
807813
citations: parseCitationFromMessage(messages[index - 1]),
@@ -812,15 +818,15 @@ const Chat = () => {
812818
}}
813819
onCitationClicked={c => onShowCitation(c)}
814820
onExectResultClicked={() => onShowExecResult(answerId)}
815-
/>
821+
/>}
816822
</div>
817823
) : answer.role === ERROR ? (
818824
<div className={styles.chatMessageError}>
819825
<Stack horizontal className={styles.chatMessageErrorContent}>
820826
<ErrorCircleRegular className={styles.errorIcon} style={{ color: 'rgba(182, 52, 67, 1)' }} />
821827
<span>Error</span>
822828
</Stack>
823-
<span className={styles.chatMessageErrorContent}>{answer.content}</span>
829+
<span className={styles.chatMessageErrorContent}>{typeof answer.content === "string" && answer.content}</span>
824830
</div>
825831
) : null}
826832
</>

0 commit comments

Comments
 (0)