Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
660 lines
23 KiB
Go
660 lines
23 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/gochat/gochat/internal/llm"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// CopilotService implements business logic for Copilot (conversation assistance) operations.
|
|
// Reference: Chatwoot enterprise/app/controllers/api/v1/copilot_threads_controller.rb
|
|
type CopilotService struct {
|
|
threadRepo *repository.CopilotThreadRepo
|
|
messageRepo *repository.CopilotMessageRepo
|
|
suggestionRepo *repository.CopilotSuggestionRepo
|
|
assistantRepo *repository.CaptainAssistantRepo
|
|
llmProvider llm.Provider
|
|
responseBackend CopilotResponseBackend
|
|
worker *worker.WorkerPool
|
|
}
|
|
|
|
type CopilotResponseBackend interface {
|
|
GenerateCopilotResponse(ctx context.Context, req CopilotResponseRequest) ([]CopilotGeneratedMessage, error)
|
|
}
|
|
|
|
type CopilotResponseRequest struct {
|
|
AccountID uint
|
|
UserID uint
|
|
ConversationID uint
|
|
Thread *model.CopilotThread
|
|
Assistant *model.CaptainAssistant
|
|
Message string
|
|
}
|
|
|
|
type CopilotGeneratedMessage struct {
|
|
MessageType model.CopilotMessageType
|
|
Message map[string]any
|
|
}
|
|
|
|
// NewCopilotService creates a new CopilotService.
|
|
func NewCopilotService(
|
|
threadRepo *repository.CopilotThreadRepo,
|
|
messageRepo *repository.CopilotMessageRepo,
|
|
suggestionRepo *repository.CopilotSuggestionRepo,
|
|
llmProvider llm.Provider,
|
|
assistantRepo ...*repository.CaptainAssistantRepo,
|
|
) *CopilotService {
|
|
s := &CopilotService{
|
|
threadRepo: threadRepo,
|
|
messageRepo: messageRepo,
|
|
suggestionRepo: suggestionRepo,
|
|
llmProvider: llmProvider,
|
|
}
|
|
if len(assistantRepo) > 0 {
|
|
s.assistantRepo = assistantRepo[0]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *CopilotService) SetWorkerPool(wp *worker.WorkerPool) {
|
|
s.worker = wp
|
|
RegisterCopilotResponseJobs(wp, s)
|
|
}
|
|
|
|
func (s *CopilotService) SetResponseBackend(backend CopilotResponseBackend) {
|
|
s.responseBackend = backend
|
|
}
|
|
|
|
const CopilotUnavailableMessage = "You are out of Copilot credits. You can buy more credits from the billing section."
|
|
|
|
// --- Thread CRUD ---
|
|
|
|
// CreateThreadRequest is the DTO for creating a copilot thread.
|
|
type CreateThreadRequest struct {
|
|
Title string `json:"title"`
|
|
Message string `json:"message"`
|
|
AssistantID uint `json:"assistant_id"`
|
|
ConversationID uint `json:"conversation_id"`
|
|
}
|
|
|
|
// CreateThread creates a new CopilotThread for a user.
|
|
func (s *CopilotService) CreateThread(ctx context.Context, accountID, userID uint, req *CreateThreadRequest) (*model.CopilotThread, error) {
|
|
message := strings.TrimSpace(req.Message)
|
|
if message == "" {
|
|
message = strings.TrimSpace(req.Title)
|
|
}
|
|
if message == "" {
|
|
return nil, errors.New("Message is required")
|
|
}
|
|
if req.AssistantID == 0 {
|
|
return nil, errors.New("assistant_id is required")
|
|
}
|
|
if s.assistantRepo != nil {
|
|
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID); err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
}
|
|
assistantID := req.AssistantID
|
|
thread := &model.CopilotThread{
|
|
AccountID: accountID,
|
|
UserID: userID,
|
|
AssistantID: &assistantID,
|
|
Title: message,
|
|
}
|
|
|
|
if err := s.threadRepo.Create(ctx, thread); err != nil {
|
|
applogger.L().Errorf("Create copilot thread: %v", err)
|
|
return nil, fmt.Errorf("create thread: %w", err)
|
|
}
|
|
|
|
userMsg, err := s.CreateThreadMessage(ctx, accountID, userID, thread.ID, &SendMessageRequest{Content: message, ConversationID: req.ConversationID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.worker != nil {
|
|
if err := s.enqueueCopilotResponse(ctx, accountID, userID, thread.ID, req.ConversationID, userMsg); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, thread.ID)
|
|
}
|
|
if _, err := s.createAssistantReply(ctx, accountID, thread.ID, userMsg, req.ConversationID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, thread.ID)
|
|
}
|
|
|
|
// GetThread retrieves an account/user scoped thread by ID, including its messages.
|
|
func (s *CopilotService) GetThread(ctx context.Context, accountID, userID, id uint) (*model.CopilotThread, error) {
|
|
thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, id)
|
|
if err != nil {
|
|
applogger.L().Errorf("Get copilot thread: %v", err)
|
|
return nil, fmt.Errorf("get thread: %w", err)
|
|
}
|
|
return thread, nil
|
|
}
|
|
|
|
func (s *CopilotService) GetThreadByID(ctx context.Context, id uint) (*model.CopilotThread, error) {
|
|
thread, err := s.threadRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
applogger.L().Errorf("Get copilot thread: %v", err)
|
|
return nil, fmt.Errorf("get thread: %w", err)
|
|
}
|
|
return thread, nil
|
|
}
|
|
|
|
// ListThreads retrieves threads for a user with pagination.
|
|
func (s *CopilotService) ListThreads(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.CopilotThread, int64, error) {
|
|
threads, count, err := s.threadRepo.ListByUser(ctx, accountID, userID, offset, limit)
|
|
if err != nil {
|
|
applogger.L().Errorf("List copilot threads: %v", err)
|
|
return nil, 0, fmt.Errorf("list threads: %w", err)
|
|
}
|
|
return threads, count, nil
|
|
}
|
|
|
|
// --- Message Operations ---
|
|
|
|
// SendMessageRequest is the DTO for sending a message in a copilot thread.
|
|
type SendMessageRequest struct {
|
|
Content string `json:"content"`
|
|
Message string `json:"message"`
|
|
ConversationID uint `json:"conversation_id"`
|
|
}
|
|
|
|
// SendMessageResult holds the result of sending a message (includes the assistant reply).
|
|
type SendMessageResult struct {
|
|
UserMessage *model.CopilotMessage `json:"user_message"`
|
|
AssistantMessage *model.CopilotMessage `json:"assistant_message"`
|
|
}
|
|
|
|
// SendMessage sends a user message and generates an assistant reply via LLM.
|
|
// Reference: Chatwoot CopilotThread#send_message
|
|
func (s *CopilotService) SendMessage(ctx context.Context, accountID, userID, threadID uint, req *SendMessageRequest) (*SendMessageResult, error) {
|
|
thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("thread not found: %w", err)
|
|
}
|
|
|
|
userMsg, err := s.CreateThreadMessage(ctx, accountID, userID, threadID, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if s.worker != nil {
|
|
if err := s.enqueueCopilotResponse(ctx, accountID, userID, threadID, req.ConversationID, userMsg); err != nil {
|
|
return &SendMessageResult{UserMessage: userMsg}, err
|
|
}
|
|
return &SendMessageResult{UserMessage: userMsg}, nil
|
|
}
|
|
|
|
assistantMsg, err := s.createAssistantReply(ctx, accountID, threadID, userMsg, req.ConversationID)
|
|
if err != nil {
|
|
return &SendMessageResult{UserMessage: userMsg}, err
|
|
}
|
|
|
|
_ = thread
|
|
return &SendMessageResult{UserMessage: userMsg, AssistantMessage: assistantMsg}, nil
|
|
}
|
|
|
|
func (s *CopilotService) enqueueCopilotResponse(ctx context.Context, accountID, userID, threadID, conversationID uint, userMsg *model.CopilotMessage) error {
|
|
if s.worker == nil || userMsg == nil {
|
|
return nil
|
|
}
|
|
_, err := s.worker.Enqueue(ctx, TaskTypeCaptainCopilotResponse, captainCopilotResponseJob{
|
|
AccountID: accountID,
|
|
UserID: userID,
|
|
ConversationID: conversationID,
|
|
CopilotThreadID: threadID,
|
|
MessageID: userMsg.ID,
|
|
Message: userMsg.GetMessageContent(),
|
|
},
|
|
worker.WithMaxAttempts(3),
|
|
worker.WithIdempotencyKey(fmt.Sprintf("captain:copilot_response:%d", userMsg.ID)),
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *CopilotService) GenerateCopilotResponseByAccount(ctx context.Context, accountID, userID, threadID, conversationID, messageID uint, message string) ([]model.CopilotMessage, error) {
|
|
thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("thread not found: %w", err)
|
|
}
|
|
if messageID != 0 {
|
|
stored, err := s.messageRepo.GetByID(ctx, messageID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("message not found: %w", err)
|
|
}
|
|
if stored.AccountID != accountID || stored.CopilotThreadID != threadID || stored.MessageType != model.CopilotMessageTypeUser {
|
|
return nil, fmt.Errorf("message does not belong to copilot thread")
|
|
}
|
|
message = stored.GetMessageContent()
|
|
}
|
|
assistant := &thread.Assistant
|
|
if thread.AssistantID == nil || thread.Assistant.ID == 0 {
|
|
return nil, fmt.Errorf("assistant not found")
|
|
}
|
|
|
|
generated, err := s.generateCopilotMessages(ctx, accountID, userID, conversationID, thread, assistant, message)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
created := make([]model.CopilotMessage, 0, len(generated))
|
|
for _, item := range generated {
|
|
messageType := item.MessageType
|
|
if messageType == "" {
|
|
messageType = model.CopilotMessageTypeAssistant
|
|
}
|
|
raw, err := json.Marshal(item.Message)
|
|
if err != nil {
|
|
return created, fmt.Errorf("marshal copilot response message: %w", err)
|
|
}
|
|
msg := &model.CopilotMessage{AccountID: accountID, CopilotThreadID: threadID, MessageType: messageType, Message: raw}
|
|
if err := s.messageRepo.Create(ctx, msg); err != nil {
|
|
return created, fmt.Errorf("save copilot response message: %w", err)
|
|
}
|
|
reloaded, err := s.messageRepo.GetByID(ctx, msg.ID)
|
|
if err != nil {
|
|
return created, err
|
|
}
|
|
created = append(created, *reloaded)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (s *CopilotService) generateCopilotMessages(ctx context.Context, accountID, userID, conversationID uint, thread *model.CopilotThread, assistant *model.CaptainAssistant, message string) ([]CopilotGeneratedMessage, error) {
|
|
if s.responseBackend != nil {
|
|
return s.responseBackend.GenerateCopilotResponse(ctx, CopilotResponseRequest{AccountID: accountID, UserID: userID, ConversationID: conversationID, Thread: thread, Assistant: assistant, Message: message})
|
|
}
|
|
content := CopilotUnavailableMessage
|
|
if s.llmProvider != nil {
|
|
generated, err := s.generateAssistantContent(ctx, thread, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(generated) != "" {
|
|
content = generated
|
|
}
|
|
}
|
|
return []CopilotGeneratedMessage{{MessageType: model.CopilotMessageTypeAssistant, Message: map[string]any{"content": content}}}, nil
|
|
}
|
|
|
|
func (s *CopilotService) CreateThreadMessage(ctx context.Context, accountID, userID, threadID uint, req *SendMessageRequest) (*model.CopilotMessage, error) {
|
|
content := strings.TrimSpace(req.Message)
|
|
if content == "" {
|
|
content = strings.TrimSpace(req.Content)
|
|
}
|
|
if content == "" {
|
|
return nil, errors.New("Message is required")
|
|
}
|
|
thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("thread not found: %w", err)
|
|
}
|
|
message, _ := json.Marshal(map[string]any{"content": content})
|
|
userMsg := &model.CopilotMessage{AccountID: thread.AccountID, CopilotThreadID: thread.ID, MessageType: model.CopilotMessageTypeUser, Message: message}
|
|
if err := s.messageRepo.Create(ctx, userMsg); err != nil {
|
|
applogger.L().Errorf("SendMessage create user message: %v", err)
|
|
return nil, fmt.Errorf("save user message: %w", err)
|
|
}
|
|
return s.messageRepo.GetByID(ctx, userMsg.ID)
|
|
}
|
|
|
|
func (s *CopilotService) ListThreadMessages(ctx context.Context, accountID, userID, threadID uint, page, perPage int) ([]model.CopilotMessage, int64, error) {
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if perPage <= 0 {
|
|
perPage = 1000
|
|
}
|
|
thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("thread not found: %w", err)
|
|
}
|
|
return s.messageRepo.FindByThreadID(ctx, thread.ID, (page-1)*perPage, perPage)
|
|
}
|
|
|
|
func (s *CopilotService) createAssistantReply(ctx context.Context, accountID, threadID uint, userMsg *model.CopilotMessage, conversationID uint) (*model.CopilotMessage, error) {
|
|
thread, err := s.threadRepo.GetByID(ctx, threadID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("thread not found: %w", err)
|
|
}
|
|
content := CopilotUnavailableMessage
|
|
if s.llmProvider != nil {
|
|
generated, err := s.generateAssistantContent(ctx, thread, userMsg.GetMessageContent())
|
|
if err == nil && strings.TrimSpace(generated) != "" {
|
|
content = generated
|
|
} else if err != nil {
|
|
applogger.L().Warnf("Copilot LLM unavailable: %v", err)
|
|
}
|
|
}
|
|
message, _ := json.Marshal(map[string]any{"content": content})
|
|
assistantMsg := &model.CopilotMessage{AccountID: accountID, CopilotThreadID: threadID, MessageType: model.CopilotMessageTypeAssistant, Message: message}
|
|
if err := s.messageRepo.Create(ctx, assistantMsg); err != nil {
|
|
return nil, fmt.Errorf("save assistant message: %w", err)
|
|
}
|
|
_ = conversationID
|
|
return s.messageRepo.GetByID(ctx, assistantMsg.ID)
|
|
}
|
|
|
|
func (s *CopilotService) generateAssistantContent(ctx context.Context, thread *model.CopilotThread, content string) (string, error) {
|
|
|
|
// Build conversation history for LLM
|
|
// Convert model.ChatMessage (from PreviousHistory) to llm.ChatMessage
|
|
modelHistory := thread.PreviousHistory(thread.Messages)
|
|
var llmHistory []llm.ChatMessage
|
|
for _, m := range modelHistory {
|
|
llmHistory = append(llmHistory, llm.ChatMessage{
|
|
Role: m.Role,
|
|
Content: m.Content,
|
|
})
|
|
}
|
|
if strings.TrimSpace(content) != "" {
|
|
llmHistory = append(llmHistory, llm.ChatMessage{
|
|
Role: "user",
|
|
Content: content,
|
|
})
|
|
}
|
|
|
|
// Build system prompt
|
|
systemPrompt := "You are an AI assistant helping a customer support agent."
|
|
if thread.AssistantID != nil {
|
|
systemPrompt += " Use the context provided by the captain assistant."
|
|
}
|
|
|
|
messages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
}
|
|
messages = append(messages, llmHistory...)
|
|
|
|
// Call LLM for assistant response
|
|
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Model: "gpt-4",
|
|
Messages: messages,
|
|
Temperature: 0.7,
|
|
MaxTokens: 1024,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("SendMessage LLM call: %v", err)
|
|
return "", fmt.Errorf("llm generation failed: %w", err)
|
|
}
|
|
|
|
assistantContent := ""
|
|
if len(llmResp.Choices) > 0 {
|
|
assistantContent = llmResp.Choices[0].Message.Content
|
|
}
|
|
|
|
return assistantContent, nil
|
|
}
|
|
|
|
// --- Suggested Replies & Summary ---
|
|
|
|
// SuggestedRepliesResult holds suggested reply options for a conversation.
|
|
type SuggestedRepliesResult struct {
|
|
Replies []string `json:"replies"`
|
|
}
|
|
|
|
// GetSuggestedReplies generates reply suggestions for a conversation.
|
|
// Reference: Chatwoot Captain::AssistanceDriver#suggested_replies
|
|
func (s *CopilotService) GetSuggestedReplies(ctx context.Context, accountID uint, conversationContext string) (*SuggestedRepliesResult, error) {
|
|
systemPrompt := "You are an AI assistant helping a customer support agent. Based on the conversation context, suggest 3 concise reply options. Return them as a JSON array of strings."
|
|
|
|
messages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: "Conversation context:\n" + conversationContext + "\n\nSuggest 3 reply options."},
|
|
}
|
|
|
|
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Model: "gpt-4",
|
|
Messages: messages,
|
|
Temperature: 0.7,
|
|
MaxTokens: 512,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("GetSuggestedReplies LLM call: %v", err)
|
|
return nil, fmt.Errorf("llm generation failed: %w", err)
|
|
}
|
|
|
|
if len(llmResp.Choices) == 0 {
|
|
return &SuggestedRepliesResult{Replies: []string{}}, nil
|
|
}
|
|
|
|
// Try to parse the response as a JSON array of strings
|
|
var replies []string
|
|
content := llmResp.Choices[0].Message.Content
|
|
if err := json.Unmarshal([]byte(content), &replies); err != nil {
|
|
// Fallback: split by newline if not valid JSON array
|
|
replies = splitReplies(content)
|
|
}
|
|
|
|
return &SuggestedRepliesResult{Replies: replies}, nil
|
|
}
|
|
|
|
// SummarizeConversationResult holds the conversation summary.
|
|
type SummarizeConversationResult struct {
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
// SummarizeConversation generates a summary of the conversation.
|
|
// Reference: Chatwoot Captain::AssistanceDriver#summarize
|
|
func (s *CopilotService) SummarizeConversation(ctx context.Context, accountID uint, conversationContext string) (*SummarizeConversationResult, error) {
|
|
systemPrompt := "You are an AI assistant. Summarize the following customer support conversation concisely, highlighting key issues, resolution status, and any action items."
|
|
|
|
messages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: "Conversation:\n" + conversationContext},
|
|
}
|
|
|
|
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Model: "gpt-4",
|
|
Messages: messages,
|
|
Temperature: 0.3,
|
|
MaxTokens: 256,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("SummarizeConversation LLM call: %v", err)
|
|
return nil, fmt.Errorf("llm generation failed: %w", err)
|
|
}
|
|
|
|
if len(llmResp.Choices) == 0 {
|
|
return &SummarizeConversationResult{Summary: ""}, nil
|
|
}
|
|
|
|
return &SummarizeConversationResult{
|
|
Summary: llmResp.Choices[0].Message.Content,
|
|
}, nil
|
|
}
|
|
|
|
// jsonEscape escapes a string for safe embedding in JSON content.
|
|
func jsonEscape(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
// Strip surrounding quotes from json.Marshal output
|
|
if len(b) >= 2 {
|
|
return string(b[1 : len(b)-1])
|
|
}
|
|
return s
|
|
}
|
|
|
|
// splitReplies tries to split raw LLM text into individual reply suggestions.
|
|
func splitReplies(content string) []string {
|
|
var replies []string
|
|
lines := splitLines(content)
|
|
for _, line := range lines {
|
|
trimmed := trimReplyPrefix(line)
|
|
if trimmed != "" {
|
|
replies = append(replies, trimmed)
|
|
}
|
|
}
|
|
if len(replies) == 0 && content != "" {
|
|
replies = []string{content}
|
|
}
|
|
return replies
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
line := s[start:i]
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func trimReplyPrefix(s string) string {
|
|
// Remove common numbered prefixes like "1.", "1) ", "- "
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '.' || s[i] == ')' || s[i] == '-' {
|
|
return s[i+1:]
|
|
}
|
|
if s[i] == ' ' {
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return s
|
|
}
|
|
|
|
// --- Thread deletion & translation ---
|
|
|
|
// DeleteThread deletes an account/user scoped copilot thread by ID.
|
|
func (s *CopilotService) DeleteThread(ctx context.Context, accountID, userID, id uint) error {
|
|
if _, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, id); err != nil {
|
|
return fmt.Errorf("thread not found: %w", err)
|
|
}
|
|
if err := s.messageRepo.DeleteByThread(ctx, id); err != nil {
|
|
return fmt.Errorf("delete thread messages: %w", err)
|
|
}
|
|
if err := s.threadRepo.DeleteByAccountUser(ctx, accountID, userID, id); err != nil {
|
|
applogger.L().Errorf("Delete copilot thread: %v", err)
|
|
return fmt.Errorf("delete thread: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TranslateRequest is the DTO for translating a message.
|
|
type TranslateRequest struct {
|
|
Content string `json:"content" validate:"required"`
|
|
TargetLanguage string `json:"target_language" validate:"required"`
|
|
}
|
|
|
|
// TranslateResult holds the translated message.
|
|
type TranslateResult struct {
|
|
TranslatedContent string `json:"translated_content"`
|
|
SourceLanguage string `json:"source_language"`
|
|
TargetLanguage string `json:"target_language"`
|
|
}
|
|
|
|
// TranslateMessage translates a message to the target language using LLM.
|
|
func (s *CopilotService) TranslateMessage(ctx context.Context, accountID uint, req *TranslateRequest) (*TranslateResult, error) {
|
|
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Messages: []llm.ChatMessage{
|
|
{Role: "system", Content: fmt.Sprintf("You are a translator. Translate the user's message to %s. Return only the translated text, nothing else.", req.TargetLanguage)},
|
|
{Role: "user", Content: req.Content},
|
|
},
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("TranslateMessage LLM: %v", err)
|
|
return nil, fmt.Errorf("translate message: %w", err)
|
|
}
|
|
|
|
translated := ""
|
|
if len(llmResp.Choices) > 0 {
|
|
translated = llmResp.Choices[0].Message.Content
|
|
}
|
|
|
|
return &TranslateResult{
|
|
TranslatedContent: translated,
|
|
TargetLanguage: req.TargetLanguage,
|
|
}, nil
|
|
}
|
|
|
|
// --- Copilot Suggestion Messages ---
|
|
// Reference: Chatwoot enterprise/app/controllers/api/v1/copilot_messages_controller.rb
|
|
// Conversation-level suggestion/reply/summary messages (distinct from thread-based CopilotMessage)
|
|
|
|
// CreateSuggestionRequest is the DTO for creating a copilot suggestion message.
|
|
type CreateSuggestionRequest struct {
|
|
ConversationID uint `json:"conversation_id" validate:"required"`
|
|
Content string `json:"content" validate:"required"`
|
|
SuggestionType model.CopilotSuggestionType `json:"suggestion_type"`
|
|
}
|
|
|
|
// ListSuggestionResult holds paginated suggestion messages.
|
|
type ListSuggestionResult struct {
|
|
Messages []model.CopilotSuggestionMessage `json:"messages"`
|
|
TotalCount int64 `json:"total_count"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"per_page"`
|
|
}
|
|
|
|
// GetCopilotSuggestions retrieves suggestion messages for a conversation.
|
|
func (s *CopilotService) GetCopilotSuggestions(ctx context.Context, accountID, conversationID uint, page, perPage int) (*ListSuggestionResult, error) {
|
|
offset := (page - 1) * perPage
|
|
msgs, count, err := s.suggestionRepo.FindByConversationID(ctx, accountID, conversationID, offset, perPage)
|
|
if err != nil {
|
|
applogger.L().Errorf("GetCopilotSuggestions: %v", err)
|
|
return nil, fmt.Errorf("get copilot suggestions: %w", err)
|
|
}
|
|
return &ListSuggestionResult{
|
|
Messages: msgs,
|
|
TotalCount: count,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
}, nil
|
|
}
|
|
|
|
// CreateCopilotSuggestion creates a new copilot suggestion message.
|
|
func (s *CopilotService) CreateCopilotSuggestion(ctx context.Context, accountID uint, req *CreateSuggestionRequest) (*model.CopilotSuggestionMessage, error) {
|
|
if req.ConversationID == 0 || strings.TrimSpace(req.Content) == "" {
|
|
return nil, errors.New("conversation_id and content are required")
|
|
}
|
|
suggestionType := req.SuggestionType
|
|
if suggestionType == "" {
|
|
suggestionType = model.CopilotSuggestionTypeSuggestion
|
|
}
|
|
|
|
msg := &model.CopilotSuggestionMessage{
|
|
AccountID: accountID,
|
|
ConversationID: req.ConversationID,
|
|
Content: req.Content,
|
|
SuggestionType: suggestionType,
|
|
Status: model.CopilotSuggestionStatusPending,
|
|
}
|
|
|
|
if err := s.suggestionRepo.Create(ctx, msg); err != nil {
|
|
applogger.L().Errorf("CreateCopilotSuggestion: %v", err)
|
|
return nil, fmt.Errorf("create copilot suggestion: %w", err)
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// UpdateSuggestionStatus updates the status of a copilot suggestion message.
|
|
func (s *CopilotService) UpdateSuggestionStatus(ctx context.Context, id uint, status model.CopilotSuggestionStatus) (*model.CopilotSuggestionMessage, error) {
|
|
msg, err := s.suggestionRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("suggestion not found: %w", err)
|
|
}
|
|
if err := s.suggestionRepo.UpdateStatus(ctx, id, status); err != nil {
|
|
applogger.L().Errorf("UpdateSuggestionStatus: %v", err)
|
|
return nil, fmt.Errorf("update suggestion status: %w", err)
|
|
}
|
|
msg.Status = status
|
|
return msg, nil
|
|
}
|