Skip to content

Commit dd052b5

Browse files
committed
feat(condo): DOMA-13253 add file attachments to ai chat
1 parent f8f115d commit dd052b5

18 files changed

Lines changed: 922 additions & 51 deletions

apps/condo/domains/ai/components/AIChat/AIChat.module.css

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@
7676
}
7777

7878
.user-message-container {
79+
display: flex;
80+
flex-direction: column;
81+
gap: 8px;
82+
align-items: flex-end;
83+
max-width: 100%;
84+
}
85+
86+
.user-message-row {
7987
display: flex;
8088
gap: 4px;
8189
align-items: center;
@@ -93,14 +101,25 @@
93101
}
94102

95103
.user-message-bubble {
96-
display: inline-block;
104+
display: inline-flex;
105+
flex-direction: column;
106+
gap: 8px;
107+
align-items: flex-start;
97108
max-width: 100%;
98109
padding: 6px 8px;
99110
background: var(--condo-global-color-white);
100111
border-radius: var(--condo-global-border-radius-medium);
101112
box-shadow: 0 1px 2px rgb(0 0 0 / 3%);
102113
}
103114

115+
.user-message-attachments {
116+
display: flex;
117+
flex-direction: column;
118+
gap: 4px;
119+
align-items: flex-start;
120+
width: 100%;
121+
}
122+
104123
.assistant-message {
105124
display: flex;
106125
justify-content: flex-start;

apps/condo/domains/ai/components/AIChat/AIChat.tsx

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ import { useFeatureFlags } from '@open-condo/featureflags/FeatureFlagsContext'
66
import { useAuth } from '@open-condo/next/auth'
77
import { useIntl } from '@open-condo/next/intl'
88
import { useOrganization } from '@open-condo/next/organization'
9-
import { Button, Input, Markdown, Space, Tooltip, Typography } from '@open-condo/ui'
9+
import { Button, Markdown, Space, Tooltip, Typography } from '@open-condo/ui'
1010

1111
import { CHAT_WITH_CONDO_FLOW_TYPE, TASK_STATUSES } from '@condo/domains/ai/constants'
12+
import { useAIChatAttachments, type AIChatAttachmentMeta } from '@condo/domains/ai/hooks/useAIChatAttachments'
1213
import { useAIFlow } from '@condo/domains/ai/hooks/useAIFlow'
1314
import { runToolCall, ToolCallResult } from '@condo/domains/ai/utils/toolCalls'
1415
import { AI_CHAT_BUTTON_CONFIG } from '@condo/domains/common/constants/featureflags'
1516
import { analytics } from '@condo/domains/common/utils/analytics'
1617
import { LocalStorageManager } from '@condo/domains/common/utils/localStorageManager'
1718

1819
import styles from './AIChat.module.css'
20+
import { AIChatInput } from './AIChatInput'
1921
import { AIChatMessage } from './AIChatMessage'
2022

2123
const STORAGE_KEY = 'condo-ai-chat-history'
@@ -115,9 +117,16 @@ function parseAiChatButtonConfigFromFlag (raw: unknown): AiChatButtonConfig | nu
115117
return { welcomeMessage, buttons }
116118
}
117119

120+
export type MessageAttachmentDisplay = {
121+
name: string
122+
mimeType?: string
123+
url?: string
124+
}
125+
118126
export type MessageContent = {
119127
text: string
120128
suggestions?: string[]
129+
attachments?: MessageAttachmentDisplay[]
121130
}
122131

123132
export type Message = {
@@ -130,6 +139,14 @@ export type Message = {
130139
copyable?: boolean
131140
}
132141

142+
type ExecuteAIMessageOptions = {
143+
additionalContext?: Record<string, unknown>
144+
toolCallDepth?: number
145+
messageId?: string | null
146+
scenarioButtonId?: string | null
147+
attachments?: AIChatAttachmentMeta[]
148+
}
149+
133150
type AIChatProps = {
134151
aiSessionId: string
135152
onSessionChange?: (sessionId: string) => void
@@ -166,8 +183,6 @@ export const AIChat: React.FC<AIChatProps> = ({
166183

167184
const messagesEndRef = useRef<HTMLDivElement>(null)
168185
const inputRef = useRef<any>(null)
169-
const inputContainerRef = useRef<HTMLDivElement>(null)
170-
const [inputContainerHeight, setInputContainerHeight] = useState(0)
171186

172187
const [{ execute, resume }, { loading, currentTaskId }] = useAIFlow<{ answer: string, toolCalls?: Array<{ name: string, args: any }> }>({
173188
aiSessionId: aiSessionId,
@@ -221,6 +236,16 @@ export const AIChat: React.FC<AIChatProps> = ({
221236
return !(currentTaskId && loading)
222237
}, [currentTaskId, loading])
223238

239+
const attachments = useAIChatAttachments({
240+
onFileListChange: () => inputRef.current?.focus(),
241+
})
242+
const attachmentsUploading = attachments ? attachments.uploading : false
243+
const canSendWithAttachments = attachments ? attachments.canSendWithAttachments : false
244+
245+
const canSendMessage = useMemo(() => {
246+
return Boolean(inputValue.trim() || canSendWithAttachments) && !attachmentsUploading
247+
}, [inputValue, canSendWithAttachments, attachmentsUploading])
248+
224249
const addMessage = useCallback((newMessage: Message) => {
225250
setMessages(prev => {
226251
return [...prev, newMessage]
@@ -301,26 +326,18 @@ export const AIChat: React.FC<AIChatProps> = ({
301326
}, 100)
302327
}, [])
303328

304-
useEffect(() => {
305-
if (!inputContainerRef.current) return
306-
307-
const observer = new ResizeObserver((entries) => {
308-
for (const entry of entries) {
309-
setInputContainerHeight(entry.contentRect.height)
310-
}
311-
})
312-
313-
observer.observe(inputContainerRef.current)
314-
return () => observer.disconnect()
315-
}, [])
316-
317329
const executeAIMessage = useCallback(async (
318330
userInput: string,
319-
additionalContext?: any,
320-
toolCallDepth = 0,
321-
messageId: string | null = null,
322-
scenarioButtonId?: string | null,
331+
options: ExecuteAIMessageOptions = {},
323332
) => {
333+
const {
334+
additionalContext,
335+
toolCallDepth = 0,
336+
messageId = null,
337+
scenarioButtonId = null,
338+
attachments: attachmentsForRequest,
339+
} = options
340+
324341
if (toolCallDepth >= MAX_TOOL_CALL_DEPTH) {
325342
addMessage({
326343
id: `depth-error-${Date.now()}`,
@@ -354,6 +371,7 @@ export const AIChat: React.FC<AIChatProps> = ({
354371
organizationId: organization?.id,
355372
...additionalContext,
356373
},
374+
...(attachmentsForRequest?.length ? { attachments: attachmentsForRequest } : {}),
357375
...(scenarioButtonId ? { button_id: scenarioButtonId } : {}),
358376
})
359377

@@ -449,7 +467,11 @@ export const AIChat: React.FC<AIChatProps> = ({
449467
}))
450468

451469
if (allToolCallResults.length > 0) {
452-
await executeAIMessage('toolCalls:', { toolCalls: allToolCallResults }, toolCallDepth + 1, toolExecutionMessage.id)
470+
await executeAIMessage('toolCalls:', {
471+
additionalContext: { toolCalls: allToolCallResults },
472+
toolCallDepth: toolCallDepth + 1,
473+
messageId: toolExecutionMessage.id,
474+
})
453475
}
454476
} catch (error) {
455477
changeMessage(toolExecutionMessage.id, {
@@ -472,18 +494,27 @@ export const AIChat: React.FC<AIChatProps> = ({
472494
}, [aiSessionId, currentTaskId, loadingLabel, errorMessage, failedToGetResponseMessage, organization, user, client, intl, addMessage, changeMessage, removeMessage, execute, toolDepthExceededMessage, noResponseMessage, executingToolsMessage, errorExecutingToolsMessage])
473495

474496
const handleSendMessage = async () => {
475-
if (!inputValue.trim() || loading || !user) return
497+
const trimmedInput = inputValue.trim()
498+
const canSend = (trimmedInput || canSendWithAttachments) && !loading && !attachmentsUploading && user
499+
if (!canSend) return
476500

501+
const attachmentsToSend = attachments ? [...attachments.readyAttachments] : []
477502
const isFirstInSession = !messages.some((msg) => msg.role === 'user')
478503
void analytics.track('ai_assistant_message_send', {
479504
source: 'typed',
480505
is_first_in_session: isFirstInSession,
481506
location: typeof window !== 'undefined' ? window.location.href : '',
507+
attachments_count: attachmentsToSend.length,
482508
})
483509

484510
const userMessage: Message = {
485511
id: Date.now().toString(),
486-
content: { text: inputValue.trim() },
512+
content: {
513+
text: trimmedInput,
514+
...(attachmentsToSend.length ? {
515+
attachments: attachmentsToSend.map(({ name, mimeType }) => ({ name, mimeType })),
516+
} : {}),
517+
},
487518
role: 'user',
488519
timestamp: new Date(),
489520
status: 'sent',
@@ -492,10 +523,10 @@ export const AIChat: React.FC<AIChatProps> = ({
492523

493524
addMessage(userMessage)
494525

495-
const currentInput = inputValue.trim()
496526
setInputValue('')
527+
attachments?.resetAttachments()
497528

498-
await executeAIMessage(currentInput)
529+
await executeAIMessage(trimmedInput, { attachments: attachmentsToSend })
499530
}
500531

501532
const handleScenarioButtonClick = useCallback(async (buttonId: string, buttonName: string) => {
@@ -506,6 +537,7 @@ export const AIChat: React.FC<AIChatProps> = ({
506537
source: 'scenario_button',
507538
is_first_in_session: isFirstInSession,
508539
location: typeof window !== 'undefined' ? window.location.href : '',
540+
attachments_count: 0,
509541
button_id: buttonId,
510542
button_name: buttonName,
511543
})
@@ -520,7 +552,7 @@ export const AIChat: React.FC<AIChatProps> = ({
520552
}
521553

522554
addMessage(userMessage)
523-
await executeAIMessage(buttonName, undefined, 0, null, buttonId)
555+
await executeAIMessage(buttonName, { scenarioButtonId: buttonId })
524556
}, [loading, user, canExecuteAIFlow, messages, addMessage, executeAIMessage])
525557

526558
const handleSuggestionButtonClick = useCallback(async (suggestedText: string) => {
@@ -531,6 +563,7 @@ export const AIChat: React.FC<AIChatProps> = ({
531563
source: 'suggestion',
532564
is_first_in_session: isFirstInSession,
533565
location: typeof window !== 'undefined' ? window.location.href : '',
566+
attachments_count: 0,
534567
button_name: suggestedText,
535568
})
536569

@@ -547,12 +580,15 @@ export const AIChat: React.FC<AIChatProps> = ({
547580
await executeAIMessage(suggestedText)
548581
}, [loading, user, canExecuteAIFlow, messages, addMessage, executeAIMessage])
549582

550-
const handleKeyPress = (e: React.KeyboardEvent) => {
551-
if (e.key === 'Enter' && !e.shiftKey) {
552-
e.preventDefault()
553-
handleSendMessage()
583+
const handleComposerKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
584+
if (e.key !== 'Enter' || e.shiftKey) return
585+
586+
e.preventDefault()
587+
588+
if (canSendMessage && canExecuteAIFlow) {
589+
void handleSendMessage()
554590
}
555-
}
591+
}, [canExecuteAIFlow, canSendMessage, handleSendMessage])
556592

557593
return (
558594
<div className={styles.chatContainer}>
@@ -624,21 +660,19 @@ export const AIChat: React.FC<AIChatProps> = ({
624660
))
625661
)}
626662
<div ref={messagesEndRef} />
627-
<div className={styles.inputSpacer} style={{ height: inputContainerHeight }} />
628663
</div>
629664

630-
<div ref={inputContainerRef} className={styles.inputContainer}>
631-
<Input.TextArea
632-
ref={inputRef}
633-
value={inputValue}
634-
onChange={(e) => setInputValue(e.target.value)}
635-
onKeyDown={handleKeyPress}
636-
onSubmit={() => handleSendMessage()}
637-
placeholder={placeholder}
638-
disabled={!canExecuteAIFlow}
639-
autoSize={{ minRows: 1, maxRows: 4 }}
640-
/>
641-
</div>
665+
<AIChatInput
666+
attachments={attachments}
667+
canExecuteAIFlow={canExecuteAIFlow}
668+
canSendMessage={canSendMessage}
669+
inputRef={inputRef}
670+
inputValue={inputValue}
671+
onInputChange={setInputValue}
672+
onInputKeyDown={handleComposerKeyDown}
673+
onSendMessage={() => void handleSendMessage()}
674+
placeholder={placeholder}
675+
/>
642676
</div>
643677
)
644678
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.input-container {
2+
padding: 16px 24px;
3+
background: white;
4+
border-top: 1px solid #f0f0f0;
5+
}
6+
7+
.text-area-container {
8+
flex: 1 1 auto;
9+
width: 100%;
10+
min-width: 0;
11+
}
12+
13+
.text-area-container :global(.condo-input.condo-input-textarea-wrapper) {
14+
width: 100%;
15+
}
16+
17+
.text-area-container :global(.condo-input-textarea) {
18+
width: 100%;
19+
}
20+
21+
.attachments-container {
22+
max-height: 210px;
23+
margin-bottom: 8px;
24+
overflow-y: auto;
25+
}
26+
27+
.attachment-container {
28+
display: flex;
29+
flex-direction: column;
30+
gap: 4px;
31+
width: 100%;
32+
}

0 commit comments

Comments
 (0)