/**
* Chat composable
* Handles chat/inquire functionality with streaming responses
*/
import { ref, reactive, nextTick } from 'vue';
export function useChat() {
// State
const chatMessages = ref([]);
const chatInput = ref('');
const isChatLoading = ref(false);
const chatMessagesRef = ref(null);
const isChatExpanded = ref(false);
// Methods
const isChatScrolledToBottom = () => {
if (!chatMessagesRef.value) return true;
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.value;
const scrollableHeight = scrollHeight - clientHeight;
if (scrollableHeight <= 0) return true;
const scrollPercentage = scrollTop / scrollableHeight;
return scrollPercentage >= 0.95; // Within bottom 5%
};
const scrollChatToBottom = () => {
if (chatMessagesRef.value) {
requestAnimationFrame(() => {
if (chatMessagesRef.value) {
chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
}
});
}
};
const sendMessage = async (recordingId) => {
if (!chatInput.value.trim() || isChatLoading.value) {
return;
}
const message = chatInput.value.trim();
if (!Array.isArray(chatMessages.value)) {
chatMessages.value = [];
}
chatMessages.value.push({ role: 'user', content: message });
chatInput.value = '';
isChatLoading.value = true;
await nextTick();
scrollChatToBottom();
let assistantMessage = null;
try {
const messageHistory = chatMessages.value
.slice(0, -1)
.map(msg => ({ role: msg.role, content: msg.content }));
const response = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recording_id: recordingId,
message: message,
message_history: messageHistory
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to get chat response');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processStream = async () => {
let isFirstChunk = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6);
if (jsonStr) {
try {
const data = JSON.parse(jsonStr);
if (data.thinking) {
const shouldScroll = isChatScrolledToBottom();
if (isFirstChunk) {
isChatLoading.value = false;
assistantMessage = reactive({
role: 'assistant',
content: '',
html: '',
thinking: data.thinking,
thinkingExpanded: false
});
chatMessages.value.push(assistantMessage);
isFirstChunk = false;
} else if (assistantMessage) {
if (assistantMessage.thinking) {
assistantMessage.thinking += '\n\n' + data.thinking;
} else {
assistantMessage.thinking = data.thinking;
}
}
if (shouldScroll) {
await nextTick();
scrollChatToBottom();
}
}
if (data.delta) {
const shouldScroll = isChatScrolledToBottom();
if (isFirstChunk) {
isChatLoading.value = false;
assistantMessage = reactive({
role: 'assistant',
content: '',
html: '',
thinking: '',
thinkingExpanded: false
});
chatMessages.value.push(assistantMessage);
isFirstChunk = false;
}
assistantMessage.content += data.delta;
if (window.marked) {
assistantMessage.html = window.marked.parse(assistantMessage.content);
} else {
assistantMessage.html = assistantMessage.content;
}
if (shouldScroll) {
await nextTick();
scrollChatToBottom();
}
}
if (data.end_of_stream) {
return;
}
if (data.error) {
throw new Error(data.error);
}
} catch (e) {
console.error('Error parsing stream data:', e);
}
}
}
}
}
};
await processStream();
} catch (error) {
console.error('Chat Error:', error);
if (assistantMessage) {
assistantMessage.content = `Error: ${error.message}`;
assistantMessage.html = `Error: ${error.message}`;
} else {
chatMessages.value.push({
role: 'assistant',
content: `Error: ${error.message}`,
html: `Error: ${error.message}`
});
}
} finally {
isChatLoading.value = false;
await nextTick();
if (isChatScrolledToBottom()) {
scrollChatToBottom();
}
}
};
const clearChat = () => {
chatMessages.value = [];
chatInput.value = '';
isChatLoading.value = false;
};
const toggleThinking = (message) => {
if (message.thinking) {
message.thinkingExpanded = !message.thinkingExpanded;
}
};
const setChatRef = (el) => {
chatMessagesRef.value = el;
};
const handleChatInput = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
// Trigger send message (caller should provide recordingId)
return true;
}
return false;
};
return {
// State
chatMessages,
chatInput,
isChatLoading,
chatMessagesRef,
isChatExpanded,
// Methods
sendMessage,
clearChat,
toggleThinking,
setChatRef,
scrollChatToBottom,
handleChatInput
};
}