Move copilot to websockets

This commit is contained in:
Pranav
2025-05-11 00:37:59 -07:00
parent bcce9cf74b
commit 34e929c705
16 changed files with 422 additions and 151 deletions
+35
View File
@@ -0,0 +1,35 @@
class CopilotChannel < ApplicationCable::Channel
def subscribed
current_user
current_account
ensure_stream
end
def message(data)
return if @current_account.blank?
Captain::ProcessCopilotMessageJob.perform_later(
assistant_id: data['assistant_id'],
message: data['message'],
options: {
user_id: @current_user.id,
conversation_id: data['conversation_id'],
previous_history: data['previous_history']
}
)
end
def ensure_stream
stream_from "copilot_#{@current_account.id}_#{@current_user.id}"
end
def current_user
@current_user ||= User.find(params[:user_id])
end
def current_account
return if current_user.blank?
@current_account ||= @current_user.accounts.find(params[:account_id])
end
end
@@ -134,10 +134,6 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
@@ -8,6 +8,7 @@ import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingBlock from 'dashboard/components/copilot/CopilotThinkingBlock.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from '../icon/Icon.vue';
import Button from '../button/Button.vue';
@@ -43,8 +44,6 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant', 'close']);
const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
@@ -113,11 +112,11 @@ watch(
</div>
</div>
</div>
<div ref="chatContainer" class="flex-1 flex px-4 py-4 overflow-y-auto">
<div
v-if="messages.length"
class="space-y-6 flex-1 flex items-start justify-center"
>
<div
ref="chatContainer"
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
>
<div v-if="messages.length" class="space-y-6 flex-1 flex flex-col">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
@@ -125,10 +124,17 @@ watch(
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
v-else-if="
message.role === 'assistant' || message.role === 'system'
"
:message="message"
:conversation-inbox-type="conversationInboxType"
/>
<CopilotThinkingBlock
v-else-if="message.role === 'assistant_thinking'"
:content="message.content"
:reasoning="message.reasoning"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
@@ -1,8 +1,8 @@
<script setup>
import { ref, computed, onMounted, watchEffect } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import CopilotActionCableConnector from 'dashboard/helpers/CopilotActionCableConnector';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -29,6 +29,7 @@ const isSidebarOpen = computed(() => getUIState.value('isCopilotSidebarOpen'));
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const copilotConnector = ref(null);
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -62,7 +63,12 @@ const handleReset = () => {
messages.value = [];
};
const sendMessage = async message => {
const sendMessage = message => {
// Ensure WebSocket is connected before sending
if (!copilotConnector.value || !isSidebarOpen.value) {
return;
}
// Add user message
messages.value.push({
id: messages.value.length + 1,
@@ -71,47 +77,66 @@ const sendMessage = async message => {
});
isCaptainTyping.value = true;
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
copilotConnector.value.sendMessage({
assistantId: activeAssistant.value.id,
conversationId: props.conversationId,
previousHistory: messages.value
.filter(m => m.role !== 'assistant_thinking')
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
});
};
const initializeWebSocket = () => {
copilotConnector.value = new CopilotActionCableConnector({
accountId: currentUser.value.account_id,
userId: currentUser.value.id,
onDisconnect: () => {
// copilotConnector.value = null;
},
onCopilotResponse: data => {
if (data.type === 'final_response') {
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.response.response,
});
isCaptainTyping.value = false;
} else {
messages.value.push({
id: new Date().getTime(),
role: 'assistant_thinking',
content: data.response.response,
reasoning: data.response.reasoning,
});
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
});
} catch (error) {
// eslint-disable-next-line
console.log(error);
} finally {
isCaptainTyping.value = false;
},
});
};
const disconnectWebSocket = () => {
if (copilotConnector.value) {
copilotConnector.value.disconnect();
copilotConnector.value = null;
}
};
onMounted(() => {
initializeWebSocket();
store.dispatch('captainAssistants/get');
});
onBeforeUnmount(() => {
disconnectWebSocket();
});
const handleClose = () => {
store.dispatch('uiState/set', { isCopilotSidebarOpen: false });
};
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script>
<template>
@@ -0,0 +1,24 @@
<script setup>
defineProps({
content: {
type: String,
required: true,
},
reasoning: {
type: String,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2 p-2 rounded bg-n-background">
<div
v-if="reasoning"
class="text-xs text-n-slate-11 border-t border-n-weak pt-2"
>
{{ reasoning }}
</div>
<p class="text-sm text-n-slate-12">{{ content }}</p>
</div>
</template>
@@ -0,0 +1,49 @@
import { createConsumer } from '@rails/actioncable';
class CopilotActionCableConnector {
constructor(
{ accountId, userId, onDisconnect, onCopilotResponse },
websocketHost = ''
) {
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
this.consumer = createConsumer(websocketURL);
this.subscription = this.consumer.subscriptions.create(
{
channel: 'CopilotChannel',
account_id: accountId,
user_id: userId,
},
{
received: this.onReceived,
disconnected: onDisconnect,
}
);
this.events = {
'copilot.response': onCopilotResponse,
};
}
onReceived = ({ event, data } = {}) => {
if (this.events[event] && typeof this.events[event] === 'function') {
this.events[event](data);
}
};
disconnect() {
this.subscription.unsubscribe();
this.consumer.disconnect();
}
sendMessage({ message, assistantId, conversationId, previousHistory }) {
this.subscription.perform('message', {
message,
assistant_id: assistantId,
conversation_id: conversationId,
previous_history: previousHistory,
});
}
}
export default CopilotActionCableConnector;
-1
View File
@@ -61,7 +61,6 @@ Rails.application.routes.draw do
resources :documents, only: [:index, :show, :create, :destroy]
resources :assistant_responses
resources :bulk_actions, only: [:create]
resources :copilot, only: [:create]
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
@@ -1,36 +0,0 @@
class Api::V1::Accounts::Captain::CopilotController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create]
def create
# First try to get the user's preferred assistant from UI settings or from the request
assistant_id = copilot_params[:assistant_id] || current_user.ui_settings&.dig('preferred_captain_assistant_id')
# Find the assistant either by ID or from inbox
assistant = if assistant_id.present?
Captain::Assistant.find_by(id: assistant_id, account_id: Current.account.id)
else
@conversation.inbox.captain_assistant
end
return render json: { message: I18n.t('captain.copilot_error') } unless assistant
response = Captain::Copilot::ChatService.new(
assistant,
previous_messages: copilot_params[:previous_messages],
conversation_history: @conversation.to_llm_text,
language: @conversation.account.locale_english_name
).generate_response(copilot_params[:message])
render json: { message: response['response'] }
end
private
def copilot_params
params.permit(:message, :assistant_id, previous_messages: [])
end
def fetch_conversation
@conversation = Current.account.conversations.find(params[:conversation_id])
end
end
+47 -54
View File
@@ -1,47 +1,30 @@
module Captain::ChatHelper
def search_documentation_tool
{
type: 'function',
function: {
name: 'search_documentation',
description: "Use this function to get documentation on functionalities you don't know about.",
parameters: {
type: 'object',
properties: {
search_query: {
type: 'string',
description: 'The search query to look up in the documentation.'
}
},
required: ['search_query']
}
}
}
end
def request_chat_completion
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" }
available_tools = @tool_registry&.registered_tools || []
response = @client.chat(
parameters: {
model: @model,
messages: @messages,
tools: [search_documentation_tool],
tools: available_tools,
response_format: { type: 'json_object' }
}
)
handle_response(response)
@response
rescue StandardError => e
Rails.logger.error { "[CAPTAIN][ChatCompletion] #{e}" }
raise e
end
def handle_response(response)
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
Rails.logger.info { "[CAPTAIN][ChatCompletion] #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
@response = JSON.parse(message['content'].strip)
JSON.parse(message['content'].strip)
end
end
@@ -55,39 +38,16 @@ module Captain::ChatHelper
def process_tool_call(tool_call)
tool_call_id = tool_call['id']
function_name = tool_call['function']['name']
arguments = JSON.parse(tool_call['function']['arguments'])
if tool_call['function']['name'] == 'search_documentation'
query = JSON.parse(tool_call['function']['arguments'])['search_query']
sections = fetch_documentation(query)
append_tool_response(sections, tool_call_id)
if @tool_registry.respond_to?(function_name)
execute_tool_call(tool_call_id, function_name, arguments)
else
append_tool_response('', tool_call_id)
process_invalid_tool_call(tool_call_id, function_name)
end
end
def fetch_documentation(query)
Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" }
@assistant
.responses
.approved
.search(query)
.map { |response| format_response(response) }.join
end
def format_response(response)
formatted_response = "
Question: #{response.question}
Answer: #{response.answer}
"
if response.documentable.present? && response.documentable.try(:external_link)
formatted_response += "
Source: #{response.documentable.external_link}
"
end
formatted_response
end
def append_tool_calls(tool_calls)
@messages << {
role: 'assistant',
@@ -95,11 +55,44 @@ module Captain::ChatHelper
}
end
def append_tool_response(sections, tool_call_id)
def append_tool_response(content, tool_call_id)
@messages << {
role: 'tool',
tool_call_id: tool_call_id,
content: "Found the following FAQs in the documentation:\n #{sections}"
content: content
}
end
def publish_to_stream(response)
@stream_writer&.call(response)
end
def execute_tool_call(tool_call_id, function_name, arguments)
publish_to_stream(
{
response: { response: "Processing tool call #{function_name}" },
type: 'tool_calls_start'
}
)
result = @tool_registry.send(function_name, arguments)
append_tool_response(result, tool_call_id)
publish_to_stream(
{
response: { response: "Received tool response #{function_name}" },
type: 'tool_response',
tool: function_name
}
)
end
def process_invalid_tool_call(tool_call_id, function_name)
append_tool_response('Tool not implemented', tool_call_id)
publish_to_stream(
{
response: { response: 'Tool not implemented' },
type: 'tool_error',
tool: function_name
}
)
end
end
@@ -0,0 +1,50 @@
class Captain::ProcessCopilotMessageJob < ApplicationJob
queue_as :default
def perform(assistant_id:, message:, options: {})
ensure_assistant(assistant_id)
ensure_user(options[:user_id])
process_message(message, options)
end
private
def ensure_assistant(assistant_id)
@assistant = Captain::Assistant.find(assistant_id)
@account = @assistant.account
end
def ensure_user(user_id)
@user = @account.users.find(user_id)
end
def process_message(message, options)
return unless @assistant
conversation = find_conversation(options[:conversation_id])
generate_chat_response(message, conversation, options[:previous_history])
end
def find_conversation(conversation_id)
return unless conversation_id
@account.conversations.find_by(display_id: conversation_id)
end
def generate_chat_response(message, conversation, previous_history)
Captain::Copilot::ChatService.new(
@assistant,
previous_messages: previous_history || [],
conversation_history: conversation&.to_llm_text,
language: @account.locale_english_name,
stream_writer: ->(data) { broadcast_response(data) }
).generate_response(message)
end
def broadcast_response(data)
ActionCable.server.broadcast(
"copilot_#{@account.id}_#{@user.id}",
{ event: 'copilot.response', data: data }
)
end
end
@@ -1,17 +1,21 @@
require 'openai'
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
def initialize(assistant, config)
super()
attr_reader :assistant, :language
def initialize(assistant, config = {})
super()
@assistant = assistant
@conversation_history = config[:conversation_history]
@previous_messages = config[:previous_messages] || []
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@language = config[:language] || 'english'
@messages = [system_message, conversation_history_context] + @previous_messages
@response = ''
@messages = build_initial_messages(config)
@stream_writer = config[:stream_writer]
end
def build_initial_messages(config)
messages = [system_message]
messages << conversation_history_context if config[:conversation_history].present?
messages + (config[:previous_messages] || [])
end
def generate_response(input)
@@ -20,6 +24,12 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
@assistant.account.increment_response_usage
publish_to_stream(
{
response: response,
type: 'final_response'
}
)
response
end
@@ -33,12 +43,14 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
end
def conversation_history_context
return if @conversation_history.blank?
{
role: 'system',
content: "
Message History with the user is below:
#{@conversation_history}
"
content: <<~HISTORY.strip
Message History with the user is below:
#{@conversation_history}
HISTORY
}
end
end
@@ -3,10 +3,10 @@ require 'openai'
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
def initialize(assistant: nil)
def initialize(assistant)
super()
@assistant = assistant
@tool_registry = Captain::ToolRegistryService.new(assistant)
@messages = [system_message]
@response = ''
end
@@ -63,7 +63,8 @@ class Captain::Llm::SystemPromptsService
You should only provide information related to #{product_name} and must not address queries about other products or external events.
[Context]
You will be provided with the message history between the support agent and the customer. Use this context to understand the conversation flow, identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
Identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
[Response Guidelines]
- Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words.
@@ -90,6 +91,8 @@ class Captain::Llm::SystemPromptsService
7. Write the response in multiple paragraphs and in markdown format.
8. DO NOT use headings in Markdown
9. Cite the sources if you used a tool to find the response.
10. Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
11. Always provide a reasoning for the response.
```json
{
@@ -0,0 +1,32 @@
class Captain::ToolRegistryService
attr_reader :registered_tools, :tools
def initialize(assistant)
@assistant = assistant
@registered_tools = []
@tools = {}
register_default_tools
end
def register_tool(tool_class)
tool = tool_class.new(@assistant)
@tools[tool.name] = tool
@registered_tools << tool.to_registry_format
end
def register_default_tools
register_tool(Captain::Tools::SearchDocumentationService)
end
def method_missing(method_name, *arguments)
if @tools.key?(method_name.to_s)
@tools[method_name.to_s].execute(*arguments)
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
@tools.key?(method_name.to_s) || super
end
end
@@ -0,0 +1,34 @@
class Captain::Tools::BaseService
attr_accessor :assistant
def initialize(assistant)
@assistant = assistant
end
def name
raise NotImplementedError, "#{self.class} must implement name"
end
def description
raise NotImplementedError, "#{self.class} must implement description"
end
def parameters
raise NotImplementedError, "#{self.class} must implement parameters"
end
def execute(arguments)
raise NotImplementedError, "#{self.class} must implement execute"
end
def to_registry_format
{
type: 'function',
function: {
name: name,
description: description,
parameters: parameters
}
}
end
end
@@ -0,0 +1,49 @@
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseService
def name
'search_documentation'
end
def description
'Search and retrieve documentation from knowledge base'
end
def parameters
{
type: 'object',
properties: {
search_query: {
type: 'string',
description: 'The search query to look up in the documentation.'
}
},
required: ['search_query']
}
end
def execute(arguments)
query = arguments['search_query']
Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" }
assistant
.responses
.approved
.search(query)
.map { |response| format_response(response) }
.join
end
private
def format_response(response)
formatted_response = "
Question: #{response.question}
Answer: #{response.answer}
"
if response.documentable.present? && response.documentable.try(:external_link)
formatted_response += "
Source: #{response.documentable.external_link}
"
end
formatted_response
end
end