Linear integration
This commit is contained in:
@@ -27,7 +27,7 @@ const props = defineProps({
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
assistants: {
|
||||
type: Array,
|
||||
@@ -102,7 +102,7 @@ watch(
|
||||
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">
|
||||
<div v-if="messages.length" class="space-y-6 flex-1 flex flex-col w-full">
|
||||
<template
|
||||
v-for="item in groupedMessages"
|
||||
:key="item.type === 'message' ? item.message.id : 'thinking-group'"
|
||||
|
||||
@@ -17,7 +17,7 @@ const props = defineProps({
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ const useCopilotResponse = () => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||
<div class="flex flex-col gap-1 text-n-slate-12 w-full">
|
||||
<div class="font-medium text-n-slate-12">{{ $t('CAPTAIN.NAME') }}</div>
|
||||
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
||||
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
||||
@@ -54,11 +54,11 @@ const useCopilotResponse = () => {
|
||||
<div
|
||||
v-else
|
||||
v-dompurify-html="messageContent"
|
||||
class="prose-sm break-words text-n-slate-11"
|
||||
class="prose-sm break-words text-n-slate-11 [&_a]:underline [&_a]:text-n-slate-11 [&_a]:hover:text-n-slate-12"
|
||||
/>
|
||||
<div class="flex flex-row mt-1">
|
||||
<Button
|
||||
v-if="!hasEmptyMessageContent"
|
||||
v-if="!hasEmptyMessageContent && false"
|
||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||
faded
|
||||
sm
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Icon from '../icon/Icon.vue';
|
||||
|
||||
const emit = defineEmits(['useSuggestion']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const promptOptions = [
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
|
||||
},
|
||||
];
|
||||
const routePromptMap = {
|
||||
conversations: [
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
|
||||
},
|
||||
],
|
||||
dashboard: [
|
||||
{
|
||||
label: 'High priority conversations',
|
||||
prompt: 'Get me a summary of all high priority open conversations',
|
||||
},
|
||||
{
|
||||
label: 'List contacts',
|
||||
prompt: 'Show me the list of contacts',
|
||||
},
|
||||
{
|
||||
label: 'Summarize articles',
|
||||
prompt: 'List articles that are in draft',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const getCurrentRoute = () => {
|
||||
const path = route.path;
|
||||
if (path.includes('/conversations')) return 'conversations';
|
||||
if (path.includes('/dashboard')) return 'dashboard';
|
||||
if (path.includes('/contacts')) return 'contacts';
|
||||
if (path.includes('/articles')) return 'articles';
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
const promptOptions = computed(() => {
|
||||
const currentRoute = getCurrentRoute();
|
||||
return routePromptMap[currentRoute] || routePromptMap.conversations;
|
||||
});
|
||||
|
||||
const handleSuggestion = opt => {
|
||||
emit('useSuggestion', t(opt.prompt));
|
||||
|
||||
@@ -5,7 +5,7 @@ import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||
import CopilotActionCableConnector from 'dashboard/helpers/CopilotActionCableConnector';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
const store = useStore();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
@@ -96,6 +96,13 @@ const initializeWebSocket = () => {
|
||||
content: data.response.response,
|
||||
});
|
||||
isCaptainTyping.value = false;
|
||||
} else if (data.type === 'ui_event') {
|
||||
if (data.event === 'ui:linear_ticket_create') {
|
||||
emitter.emit('ui:linear_ticket_create');
|
||||
setTimeout(() => {
|
||||
emitter.emit('ui:linear_ticket_create_data', data.response);
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
messages.value.push({
|
||||
id: new Date().getTime(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import SearchableDropdown from './SearchableDropdown.vue';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -204,6 +205,12 @@ const createIssue = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEmitter('ui:linear_ticket_create_data', data => {
|
||||
formState.title = data.title;
|
||||
formState.description = data.description;
|
||||
formState.priority = data.priority;
|
||||
});
|
||||
|
||||
onMounted(getTeams);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTrack } from 'dashboard/composables';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -94,6 +95,10 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
useEmitter('ui:linear_ticket_create', () => {
|
||||
shouldShowPopup.value = true;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssue();
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ module Captain::ChatHelper
|
||||
type: 'tool_calls_start'
|
||||
}
|
||||
)
|
||||
result = @tool_registry.send(function_name, arguments)
|
||||
result = @tool_registry.send(function_name, arguments.merge('user_id' => @user_id))
|
||||
append_tool_response(result, tool_call_id)
|
||||
publish_to_stream(
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ class Captain::ProcessCopilotMessageJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_chat_response(message, conversation, previous_history)
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Generating chat response for conversation_id=#{conversation&.display_id}")
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Generating chat response for conversation_id=#{conversation&.display_id}, user_id=#{@user.id}")
|
||||
Captain::Copilot::ChatService.new(
|
||||
@assistant,
|
||||
previous_messages: previous_history || [],
|
||||
@@ -46,7 +46,8 @@ class Captain::ProcessCopilotMessageJob < ApplicationJob
|
||||
additional_info: {
|
||||
language: @account.locale_english_name,
|
||||
conversation_id: conversation&.display_id,
|
||||
contact_id: conversation&.contact_id
|
||||
contact_id: conversation&.contact_id,
|
||||
user_id: @user.id
|
||||
}
|
||||
).generate_response(message)
|
||||
end
|
||||
|
||||
@@ -18,6 +18,8 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
@language = additional_info[:language] || 'english'
|
||||
@conversation_id = additional_info[:conversation_id]
|
||||
@contact_id = additional_info[:contact_id]
|
||||
@user_id = additional_info[:user_id]
|
||||
Rails.logger.info("[Captain::Copilot::ChatService::User] user_id: #{@user_id}")
|
||||
end
|
||||
|
||||
def register_tools
|
||||
@@ -27,6 +29,8 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticleService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::CreateLinearTicketsOnUiService)
|
||||
end
|
||||
|
||||
def build_initial_messages(config)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
class Captain::Tools::Copilot::CreateLinearTicketsOnUiService < Captain::Tools::BaseService
|
||||
def name
|
||||
'create_linear_tickets_on_ui'
|
||||
end
|
||||
|
||||
def description
|
||||
'Create linear tickets'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
account_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the account to create Linear tickets in'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Title of the Linear ticket'
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Description of the Linear ticket'
|
||||
},
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Priority of the ticket (0-4)',
|
||||
enum: [0, 1, 2, 3, 4]
|
||||
}
|
||||
},
|
||||
required: %w[account_id title description]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
account_id = arguments['account_id']
|
||||
title = arguments['title']
|
||||
description = arguments['description']
|
||||
priority = arguments['priority']
|
||||
user_id = arguments['user_id']
|
||||
|
||||
Rails.logger.info do
|
||||
"[CAPTAIN][CreateLinearTickets] account_id: #{account_id}, title: #{title}, description: #{description}, priority: #{priority}, user_id: #{user_id}"
|
||||
end
|
||||
|
||||
return 'Missing required parameters' if account_id.blank? || title.blank? || description.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
return 'Account not found' unless account
|
||||
|
||||
Rails.logger.info do
|
||||
"[CAPTAIN][CreateLinearTickets] Sent action cable message to copilot_#{account_id}_#{user_id}"
|
||||
end
|
||||
|
||||
# Send ActionCable message
|
||||
ActionCable.server.broadcast(
|
||||
"copilot_#{account_id}_#{user_id}",
|
||||
{
|
||||
event: 'copilot.response',
|
||||
data: {
|
||||
response: {
|
||||
account_id: account_id,
|
||||
title: title,
|
||||
description: description,
|
||||
priority: priority
|
||||
},
|
||||
event: 'ui:linear_ticket_create',
|
||||
type: 'ui_event'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
'The suggestion for the ticket is sent successfully to the agent. The agent can now create the ticket.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_issue(issue)
|
||||
{
|
||||
id: issue['id'],
|
||||
title: issue['title'],
|
||||
state: issue['state']['name'],
|
||||
priority: format_priority(issue['priority']),
|
||||
assignee: issue['assignee'] ? issue['assignee']['name'] : nil,
|
||||
description: issue['description'],
|
||||
url: issue['url']
|
||||
}
|
||||
end
|
||||
|
||||
def format_priority(priority)
|
||||
return 'No priority' if priority.nil?
|
||||
|
||||
case priority
|
||||
when 0 then 'No priority'
|
||||
when 1 then 'Low'
|
||||
when 2 then 'Medium'
|
||||
when 3 then 'High'
|
||||
when 4 then 'Urgent'
|
||||
else 'Unknown'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
class Captain::Tools::Copilot::SearchLinearService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_linear'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search for Linear issues based on a search term'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
account_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the account to search Linear issues in'
|
||||
},
|
||||
term: {
|
||||
type: 'string',
|
||||
description: 'The search term to find Linear issues'
|
||||
}
|
||||
},
|
||||
required: %w[account_id term]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
account_id = arguments['account_id']
|
||||
term = arguments['term']
|
||||
|
||||
Rails.logger.info do
|
||||
"[CAPTAIN][SearchLinear] account_id: #{account_id}, term: #{term}"
|
||||
end
|
||||
|
||||
return 'Missing required parameters' if account_id.blank? || term.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
return 'Account not found' unless account
|
||||
|
||||
linear_service = Integrations::Linear::ProcessorService.new(account: account)
|
||||
result = linear_service.search_issue(term)
|
||||
|
||||
return result[:error] if result[:error]
|
||||
|
||||
issues = result[:data]
|
||||
return 'No issues found, I should try another similar search term' if issues.blank?
|
||||
|
||||
total_count = issues.length
|
||||
|
||||
<<~RESPONSE
|
||||
Total number of issues: #{total_count}
|
||||
#{issues.map { |issue| format_issue(issue) }.join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_issue(issue)
|
||||
<<~ISSUE
|
||||
Title: #{issue['title']}
|
||||
ID: #{issue['id']}
|
||||
State: #{issue['state']['name']}
|
||||
Priority: #{format_priority(issue['priority'])}
|
||||
#{issue['assignee'] ? "Assignee: #{issue['assignee']['name']}" : 'Assignee: Unassigned'}
|
||||
#{issue['description'].present? ? "\nDescription:\n#{issue['description']}" : ''}
|
||||
ISSUE
|
||||
end
|
||||
|
||||
def format_priority(priority)
|
||||
return 'No priority' if priority.nil?
|
||||
|
||||
case priority
|
||||
when 0 then 'No priority'
|
||||
when 1 then 'Low'
|
||||
when 2 then 'Medium'
|
||||
when 3 then 'High'
|
||||
when 4 then 'Urgent'
|
||||
else 'Unknown'
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user