276 lines
7.5 KiB
Vue
276 lines
7.5 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
InputGroup,
|
||
InputGroupTextarea,
|
||
InputGroupAddon,
|
||
InputGroupButton,
|
||
InputGroupText,
|
||
} from '@/components/ui/input-group'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { ArrowUp, Loader2 } from 'lucide-vue-next'
|
||
import { useRoute } from 'vue-router'
|
||
import { useApi } from '@/composables/useApi'
|
||
|
||
interface ChatMessage {
|
||
role: 'user' | 'assistant' | 'system';
|
||
text: string;
|
||
isStreaming?: boolean;
|
||
}
|
||
|
||
interface StreamEvent {
|
||
type: string;
|
||
data?: any;
|
||
processId?: string;
|
||
nodeId?: string;
|
||
toolName?: string;
|
||
}
|
||
|
||
const chatInput = ref('')
|
||
const messages = ref<ChatMessage[]>([])
|
||
const sending = ref(false)
|
||
const route = useRoute()
|
||
const { api } = useApi()
|
||
const sessionId = ref<string | null>(null)
|
||
const eventSource = ref<EventSource | null>(null)
|
||
|
||
const getTenantId = () => {
|
||
if (!import.meta.client) return 'tenant1'
|
||
return localStorage.getItem('tenantId') || 'tenant1'
|
||
}
|
||
|
||
const buildContext = () => {
|
||
const recordId = route.params.recordId ? String(route.params.recordId) : undefined
|
||
const viewParam = route.params.view ? String(route.params.view) : undefined
|
||
const view = viewParam || (recordId ? (recordId === 'new' ? 'edit' : 'detail') : 'list')
|
||
const objectApiName = route.params.objectName
|
||
? String(route.params.objectName)
|
||
: undefined
|
||
|
||
return {
|
||
objectApiName,
|
||
view,
|
||
recordId,
|
||
route: route.fullPath,
|
||
}
|
||
}
|
||
|
||
const connectToStream = (sessionIdValue: string) => {
|
||
if (eventSource.value) {
|
||
eventSource.value.close()
|
||
}
|
||
|
||
const baseUrl = window.location.hostname === 'localhost'
|
||
? 'http://localhost:3000'
|
||
: `https://${window.location.hostname}`
|
||
|
||
eventSource.value = new EventSource(
|
||
`${baseUrl}/tenants/${getTenantId()}/ai-chat/stream?sessionId=${sessionIdValue}`
|
||
)
|
||
|
||
eventSource.value.onmessage = (event) => {
|
||
try {
|
||
const payload: StreamEvent = JSON.parse(event.data)
|
||
handleStreamEvent(payload)
|
||
} catch (error) {
|
||
console.error('Failed to parse stream event:', error)
|
||
}
|
||
}
|
||
|
||
eventSource.value.onerror = () => {
|
||
eventSource.value?.close()
|
||
eventSource.value = null
|
||
}
|
||
}
|
||
|
||
const handleStreamEvent = (event: StreamEvent) => {
|
||
switch (event.type) {
|
||
case 'agent_started':
|
||
// Agent is thinking
|
||
break
|
||
case 'processes_listed':
|
||
// Processes discovered
|
||
break
|
||
case 'process_selected':
|
||
messages.value.push({
|
||
role: 'system',
|
||
text: `🔄 Selected process: ${event.data?.processName || 'Process'}`,
|
||
})
|
||
break
|
||
case 'agent_message':
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: event.data?.message || '',
|
||
})
|
||
break
|
||
case 'node_started':
|
||
const lastMsg = messages.value[messages.value.length - 1]
|
||
if (lastMsg?.isStreaming) {
|
||
lastMsg.text += `\n⚙️ Executing step...`
|
||
}
|
||
break
|
||
case 'tool_called':
|
||
const lastToolMsg = messages.value[messages.value.length - 1]
|
||
if (lastToolMsg?.isStreaming) {
|
||
lastToolMsg.text += `\n🔧 Using tool: ${event.toolName}`
|
||
}
|
||
break
|
||
case 'need_input':
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: event.data?.prompt || 'I need some additional information from you.',
|
||
})
|
||
sending.value = false
|
||
break
|
||
case 'final':
|
||
if (event.data?.output) {
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: event.data.message || '✅ Process completed successfully!',
|
||
})
|
||
} else if (event.data?.reply) {
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: event.data.reply,
|
||
})
|
||
}
|
||
sending.value = false
|
||
break
|
||
case 'error':
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: `❌ Error: ${event.data?.error || 'An error occurred'}`,
|
||
})
|
||
sending.value = false
|
||
break
|
||
}
|
||
}
|
||
|
||
const handleSend = async () => {
|
||
if (!chatInput.value.trim()) return
|
||
|
||
const message = chatInput.value.trim()
|
||
messages.value.push({ role: 'user', text: message })
|
||
chatInput.value = ''
|
||
sending.value = true
|
||
|
||
// Add a streaming message placeholder
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: '🤔 Thinking...',
|
||
isStreaming: true
|
||
})
|
||
|
||
try {
|
||
const history = messages.value
|
||
.filter(m => m.role !== 'system' && !m.isStreaming)
|
||
.slice(0, -1)
|
||
.slice(-6)
|
||
.map(m => ({ role: m.role, text: m.text }))
|
||
|
||
const response = await api.post(`/tenants/${getTenantId()}/ai-chat/messages`, {
|
||
message,
|
||
history,
|
||
context: buildContext(),
|
||
sessionId: sessionId.value || undefined,
|
||
})
|
||
|
||
if (response.sessionId && !sessionId.value) {
|
||
sessionId.value = response.sessionId
|
||
connectToStream(response.sessionId)
|
||
}
|
||
|
||
// Remove streaming placeholder and add response
|
||
messages.value = messages.value.filter(m => !m.isStreaming)
|
||
|
||
if (response.reply) {
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: response.reply,
|
||
})
|
||
}
|
||
|
||
// If process is running, stream will handle updates
|
||
if (response.runId) {
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: '⏳ Processing workflow...',
|
||
isStreaming: true,
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
console.error('Failed to send AI chat message:', error)
|
||
messages.value = messages.value.filter(m => !m.isStreaming)
|
||
messages.value.push({
|
||
role: 'assistant',
|
||
text: error.message || 'Sorry, I ran into an error. Please try again.',
|
||
})
|
||
} finally {
|
||
sending.value = false
|
||
}
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
if (eventSource.value) {
|
||
eventSource.value.close()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="ai-chat-area w-full border-t border-border p-4 bg-neutral-50">
|
||
<div class="ai-chat-messages mb-4 space-y-3 max-h-[400px] overflow-y-auto">
|
||
<div
|
||
v-for="(message, index) in messages"
|
||
:key="`${message.role}-${index}`"
|
||
class="flex"
|
||
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
|
||
>
|
||
<div
|
||
class="max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-line"
|
||
:class="{
|
||
'bg-primary text-primary-foreground': message.role === 'user',
|
||
'bg-white border border-border text-foreground': message.role === 'assistant',
|
||
'bg-blue-50 border border-blue-200 text-blue-900 text-xs': message.role === 'system',
|
||
}"
|
||
>
|
||
<Loader2 v-if="message.isStreaming" class="inline-block size-3 animate-spin mr-1" />
|
||
{{ message.text }}
|
||
</div>
|
||
</div>
|
||
<p v-if="messages.length === 0" class="text-sm text-muted-foreground">
|
||
Ask the assistant to execute business processes, add records, or answer questions.
|
||
</p>
|
||
</div>
|
||
<InputGroup>
|
||
<InputGroupTextarea
|
||
v-model="chatInput"
|
||
placeholder="Ask, Search or Chat..."
|
||
class="min-h-[60px] rounded-lg"
|
||
@keydown.enter.exact.prevent="handleSend"
|
||
:disabled="sending"
|
||
/>
|
||
<InputGroupAddon>
|
||
<InputGroupText class="ml-auto">
|
||
52% used
|
||
</InputGroupText>
|
||
<Separator orientation="vertical" class="!h-4" />
|
||
<InputGroupButton
|
||
variant="default"
|
||
class="rounded-full"
|
||
:disabled="!chatInput.trim() || sending"
|
||
@click="handleSend"
|
||
>
|
||
<ArrowUp class="size-4" />
|
||
<span class="sr-only">Send</span>
|
||
</InputGroupButton>
|
||
</InputGroupAddon>
|
||
</InputGroup>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.ai-chat-area {
|
||
min-height: 190px;
|
||
}
|
||
</style>
|