Merge branch 'develop' into feat/email-forwarding

This commit is contained in:
Sivin Varghese
2025-05-20 23:45:15 +05:30
committed by GitHub
17 changed files with 553 additions and 61 deletions
@@ -94,7 +94,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id,
label_ids: [])
end
def fetch_hook
@@ -38,7 +38,7 @@ const currentSortBy = computed(() => {
);
});
const chatStatusOptions = [
const chatStatusOptions = computed(() => [
{
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
value: 'open',
@@ -59,9 +59,9 @@ const chatStatusOptions = [
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
value: 'all',
},
];
]);
const chatSortOptions = [
const chatSortOptions = computed(() => [
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_asc.TEXT'),
value: 'last_activity_at_asc',
@@ -94,15 +94,18 @@ const chatSortOptions = [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_desc.TEXT'),
value: 'waiting_since_desc',
},
];
]);
const activeChatStatusLabel = computed(
() =>
chatStatusOptions.find(m => m.value === chatStatusFilter.value)?.label || ''
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
?.label || ''
);
const activeChatSortLabel = computed(
() => chatSortOptions.find(m => m.value === chatSortFilter.value)?.label || ''
() =>
chatSortOptions.value.find(m => m.value === chatSortFilter.value)?.label ||
''
);
const saveSelectedFilter = (type, value) => {
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.1.0'
version: '4.2.0'
development:
<<: *shared
+16 -51
View File
@@ -1,24 +1,4 @@
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}" }
@@ -26,13 +6,12 @@ module Captain::ChatHelper
parameters: {
model: @model,
messages: @messages,
tools: [search_documentation_tool],
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' }
}
)
handle_response(response)
@response
end
def handle_response(response)
@@ -41,7 +20,7 @@ module Captain::ChatHelper
if message['tool_calls']
process_tool_calls(message['tool_calls'])
else
@response = JSON.parse(message['content'].strip)
JSON.parse(message['content'].strip)
end
end
@@ -54,38 +33,20 @@ module Captain::ChatHelper
end
def process_tool_call(tool_call)
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
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(function_name, arguments, tool_call_id)
else
append_tool_response('', tool_call_id)
process_invalid_tool_call(tool_call_id)
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
def execute_tool(function_name, arguments, tool_call_id)
result = @tool_registry.send(function_name, arguments)
append_tool_response(result, tool_call_id)
end
def append_tool_calls(tool_calls)
@@ -95,11 +56,15 @@ module Captain::ChatHelper
}
end
def append_tool_response(sections, tool_call_id)
def process_invalid_tool_call(tool_call_id)
append_tool_response('Tool not available', tool_call_id)
end
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
end
@@ -10,6 +10,8 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
@conversation_history = config[:conversation_history]
@previous_messages = config[:previous_messages] || []
@language = config[:language] || 'english'
register_tools
@messages = [system_message, conversation_history_context] + @previous_messages
@response = ''
end
@@ -25,6 +27,11 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
private
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
end
def system_message
{
role: 'system',
@@ -9,6 +9,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
@assistant = assistant
@messages = [system_message]
@response = ''
register_tools
end
def generate_response(input, previous_messages = [], role = 'user')
@@ -19,6 +20,11 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
private
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
end
def system_message
{
role: 'system',
@@ -0,0 +1,29 @@
class Captain::ToolRegistryService
attr_reader :registered_tools, :tools
def initialize(assistant)
@assistant = assistant
@registered_tools = []
@tools = {}
end
def register_tool(tool_class)
tool = tool_class.new(@assistant)
return unless tool.active?
@tools[tool.name] = tool
@registered_tools << tool.to_registry_format
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,38 @@
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
def active?
true
end
end
@@ -0,0 +1,77 @@
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService
def name
'search_linear_issues'
end
def description
'Search Linear issues based on a search term'
end
def parameters
{
type: 'object',
properties: {
term: {
type: 'string',
description: 'The search term to find Linear issues'
}
},
required: %w[term]
}
end
def execute(arguments)
return 'Linear integration is not enabled' unless active?
term = arguments['term']
Rails.logger.info "#{self.class.name}: Service called with the search term #{term}"
return 'Missing required parameters' if term.blank?
linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.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
def active?
@assistant.account.hooks.find_by(app_id: 'linear').present?
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: #{issue['description']}" : ''}
ISSUE
end
def format_priority(priority)
return 'No priority' if priority.nil?
case priority
when 0 then 'No priority'
when 1 then 'Urgent'
when 2 then 'High'
when 3 then 'Medium'
when 4 then 'Low'
else 'Unknown'
end
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.info { "#{self.class.name}: #{query}" }
responses = assistant.responses.approved.search(query)
return 'No FAQs found for the given query' if responses.empty?
responses.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
+2 -1
View File
@@ -57,7 +57,8 @@ class Linear
assigneeId: params[:assignee_id],
priority: params[:priority],
labelIds: params[:label_ids],
projectId: params[:project_id]
projectId: params[:project_id],
stateId: params[:state_id]
}.compact
mutation = Linear::Mutations.issue_create(variables)
response = post({ query: mutation })
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.1.0",
"version": "4.2.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -100,6 +100,7 @@ RSpec.describe 'Linear Integration API', type: :request do
description: 'This is a sample issue.',
assignee_id: 'user1',
priority: 'high',
state_id: 'state1',
label_ids: ['label1']
}
end
@@ -0,0 +1,112 @@
require 'rails_helper'
# Test tool implementation
class TestTool < Captain::Tools::BaseService
attr_accessor :tool_active
def initialize(*args)
super
@tool_active = true
end
def name
'test_tool'
end
def description
'A test tool for specs'
end
def parameters
{
type: 'object',
properties: {
test_param: {
type: 'string'
}
}
}
end
def execute(*args)
args
end
def active?
@tool_active
end
end
RSpec.describe Captain::ToolRegistryService do
let(:assistant) { create(:captain_assistant) }
let(:service) { described_class.new(assistant) }
describe '#initialize' do
it 'initializes with empty tools and registered_tools' do
expect(service.tools).to be_empty
expect(service.registered_tools).to be_empty
end
end
describe '#register_tool' do
let(:tool_class) { TestTool }
context 'when tool is active' do
it 'registers a new tool' do
service.register_tool(tool_class)
expect(service.tools['test_tool']).to be_a(TestTool)
expect(service.registered_tools).to include(
{
type: 'function',
function: {
name: 'test_tool',
description: 'A test tool for specs',
parameters: {
type: 'object',
properties: {
test_param: {
type: 'string'
}
}
}
}
}
)
end
end
context 'when tool is inactive' do
it 'does not register the tool' do
tool = tool_class.new(assistant)
tool.tool_active = false
allow(tool_class).to receive(:new).and_return(tool)
service.register_tool(tool_class)
expect(service.tools['test_tool']).to be_nil
expect(service.registered_tools).to be_empty
end
end
end
describe 'method_missing' do
let(:tool_class) { TestTool }
before do
service.register_tool(tool_class)
end
context 'when method corresponds to a registered tool' do
it 'executes the tool with given arguments' do
result = service.test_tool(test_param: 'arg1')
expect(result).to eq([{ test_param: 'arg1' }])
end
end
context 'when method does not correspond to a registered tool' do
it 'raises NoMethodError' do
expect { service.unknown_tool }.to raise_error(NoMethodError)
end
end
end
end
@@ -0,0 +1,125 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_linear_issues')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search Linear issues based on a search term')
end
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
term: {
type: 'string',
description: 'The search term to find Linear issues'
}
},
required: %w[term]
}
)
end
end
describe '#active?' do
context 'when Linear integration is enabled' do
before do
create(:integrations_hook, :linear, account: account)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when Linear integration is not enabled' do
it 'returns false' do
expect(service.active?).to be false
end
end
end
describe '#execute' do
context 'when Linear integration is not enabled' do
it 'returns error message' do
expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled')
end
end
context 'when Linear integration is enabled' do
let(:linear_service) { instance_double(Integrations::Linear::ProcessorService) }
before do
create(:integrations_hook, :linear, account: account)
allow(Integrations::Linear::ProcessorService).to receive(:new).and_return(linear_service)
end
context 'when term is blank' do
it 'returns error message' do
expect(service.execute({ 'term' => '' })).to eq('Missing required parameters')
end
end
context 'when search returns error' do
before do
allow(linear_service).to receive(:search_issue).and_return({ error: 'API Error' })
end
it 'returns the error message' do
expect(service.execute({ 'term' => 'test' })).to eq('API Error')
end
end
context 'when search returns no issues' do
before do
allow(linear_service).to receive(:search_issue).and_return({ data: [] })
end
it 'returns no issues found message' do
expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term')
end
end
context 'when search returns issues' do
let(:issues) do
[{
'title' => 'Test Issue',
'id' => 'TEST-123',
'state' => { 'name' => 'In Progress' },
'priority' => 4,
'assignee' => { 'name' => 'John Doe' },
'description' => 'Test description'
}]
end
before do
allow(linear_service).to receive(:search_issue).and_return({ data: issues })
end
it 'returns formatted issues' do
result = service.execute({ 'term' => 'test' })
expect(result).to include('Total number of issues: 1')
expect(result).to include('Title: Test Issue')
expect(result).to include('ID: TEST-123')
expect(result).to include('State: In Progress')
expect(result).to include('Priority: Low')
expect(result).to include('Assignee: John Doe')
expect(result).to include('Description: Test description')
end
end
end
end
end
@@ -0,0 +1,77 @@
require 'rails_helper'
RSpec.describe Captain::Tools::SearchDocumentationService do
let(:assistant) { create(:captain_assistant) }
let(:service) { described_class.new(assistant) }
let(:question) { 'How to create a new account?' }
let(:answer) { 'You can create a new account by clicking on the Sign Up button.' }
let(:external_link) { 'https://example.com/docs/create-account' }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_documentation')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search and retrieve documentation from knowledge base')
end
end
describe '#parameters' do
it 'returns the required parameters schema' do
expected_schema = {
type: 'object',
properties: {
search_query: {
type: 'string',
description: 'The search query to look up in the documentation.'
}
},
required: ['search_query']
}
expect(service.parameters).to eq(expected_schema)
end
end
describe '#execute' do
let!(:response) do
create(
:captain_assistant_response,
assistant: assistant,
question: question,
answer: answer,
status: 'approved'
)
end
let(:documentable) { create(:captain_document, external_link: external_link) }
context 'when matching responses exist' do
before do
response.update(documentable: documentable)
allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([response])
end
it 'returns formatted responses for the search query' do
result = service.execute({ 'search_query' => question })
expect(result).to include(question)
expect(result).to include(answer)
expect(result).to include(external_link)
end
end
context 'when no matching responses exist' do
before do
allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([])
end
it 'returns an empty string' do
expect(service.execute({ 'search_query' => question })).to eq('No FAQs found for the given query')
end
end
end
end
@@ -76,6 +76,7 @@ describe Integrations::Linear::ProcessorService do
description: 'Issue description',
assignee_id: 'user1',
priority: 2,
state_id: 'state1',
label_ids: %w[bug]
}
end