根因:captain_assistants.config 中 temperature 字段为字符串 "0.5",
json.Unmarshal 到 float64 字段失败,GetConfig() 返回 nil,
调用方吞掉错误后 BuildAssistantPrompt 解引用 nil cfg 导致 panic。
修复:
- GetConfig() unmarshal 失败时返回空 &cfg{} 而非 nil,防止 nil dereference
- 删除 captain_assistant_service.go 中重复的 buildSystemPrompt 函数,
统一使用 SystemPromptBuilder.BuildAssistantPrompt
- generatePlaygroundLLMResponse / GenerateResponse 增加
ErrProviderNotConfigured 检查,返回 fallback 消息而非 500
316 lines
14 KiB
Go
316 lines
14 KiB
Go
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/pgvector/pgvector-go"
|
|
)
|
|
|
|
// --- Captain Enums (M10 P10) ---
|
|
|
|
type AssistantStatus string
|
|
|
|
const (
|
|
AssistantStatusActive AssistantStatus = "active"
|
|
AssistantStatusDraft AssistantStatus = "draft"
|
|
AssistantStatusArchived AssistantStatus = "archived"
|
|
)
|
|
|
|
type DocumentStatus string
|
|
|
|
const (
|
|
DocumentStatusPending DocumentStatus = "pending"
|
|
DocumentStatusInProgress DocumentStatus = "in_progress"
|
|
DocumentStatusCompleted DocumentStatus = "completed"
|
|
DocumentStatusFailed DocumentStatus = "failed"
|
|
)
|
|
|
|
type DocumentSyncStatus string
|
|
|
|
const (
|
|
DocumentSyncStatusPending DocumentSyncStatus = "pending"
|
|
DocumentSyncStatusSynced DocumentSyncStatus = "synced"
|
|
DocumentSyncStatusStale DocumentSyncStatus = "stale"
|
|
DocumentSyncStatusFailed DocumentSyncStatus = "failed"
|
|
)
|
|
|
|
type ResponseStatus string
|
|
|
|
const (
|
|
ResponseStatusApproved ResponseStatus = "approved"
|
|
ResponseStatusPending ResponseStatus = "pending"
|
|
ResponseStatusRejected ResponseStatus = "rejected"
|
|
)
|
|
|
|
type CopilotMessageType string
|
|
|
|
const (
|
|
CopilotMessageTypeUser CopilotMessageType = "user"
|
|
CopilotMessageTypeAssistant CopilotMessageType = "assistant"
|
|
CopilotMessageTypeAssistantThinking CopilotMessageType = "assistant_thinking"
|
|
)
|
|
|
|
type ToolAuthType string
|
|
|
|
const (
|
|
ToolAuthTypeNone ToolAuthType = "none"
|
|
ToolAuthTypeBasic ToolAuthType = "basic"
|
|
ToolAuthTypeBearer ToolAuthType = "bearer"
|
|
ToolAuthTypeApiKey ToolAuthType = "api_key"
|
|
)
|
|
|
|
// --- Captain Assistant Model ---
|
|
// Reference: Chatwoot enterprise/app/models/captain/assistant.rb
|
|
// Table: captain_assistants
|
|
//
|
|
// Captain Assistant is the core orchestrator entity for AI features.
|
|
// It holds configuration (temperature, feature flags, product name),
|
|
// guardrails, and response_guidelines as JSONB.
|
|
// Associated with Account, has Documents, Responses, Scenarios, Inboxes (via CaptainInbox).
|
|
|
|
type CaptainAssistant struct {
|
|
Base
|
|
AccountID uint `gorm:"index;not null" json:"account_id"`
|
|
Name string `gorm:"size:255;not null" json:"name"`
|
|
Description string `gorm:"size:1024" json:"description"`
|
|
Config json.RawMessage `gorm:"type:jsonb;not null;default:'{}';serializer:json" json:"config"`
|
|
Guardrails json.RawMessage `gorm:"type:jsonb;serializer:json" json:"guardrails,omitempty"`
|
|
ResponseGuidelines json.RawMessage `gorm:"type:jsonb;serializer:json" json:"response_guidelines,omitempty"`
|
|
Status AssistantStatus `gorm:"size:50;default:active" json:"status"`
|
|
|
|
// Relationships (loaded via service layer or GORM preload)
|
|
Documents []CaptainDocument `gorm:"foreignKey:AssistantID" json:"documents,omitempty"`
|
|
Responses []CaptainAssistantResponse `gorm:"foreignKey:AssistantID" json:"responses,omitempty"`
|
|
Scenarios []CaptainScenario `gorm:"foreignKey:AssistantID" json:"scenarios,omitempty"`
|
|
CaptainInboxes []CaptainInbox `gorm:"foreignKey:AssistantID" json:"captain_inboxes,omitempty"`
|
|
CopilotThreads []CopilotThread `gorm:"foreignKey:AssistantID" json:"copilot_threads,omitempty"`
|
|
}
|
|
|
|
func (CaptainAssistant) TableName() string { return "captain_assistants" }
|
|
|
|
// DefaultAssistantConfig returns the default configuration for a new Captain Assistant.
|
|
// Reference: Chatwoot Captain::Assistant default config values
|
|
func DefaultAssistantConfig() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"temperature": 0.7,
|
|
"product_name": "",
|
|
"feature_flags": map[string]bool{},
|
|
"response_guidelines": "",
|
|
}
|
|
}
|
|
|
|
// AssistantConfig provides typed access to JSONB config fields.
|
|
// Reference: Chatwoot store_accessor :config, :temperature, :feature_faq, etc.
|
|
type AssistantConfig struct {
|
|
Temperature float64 `json:"temperature,omitempty"`
|
|
TemperatureConfigured bool `json:"-"`
|
|
FeatureFAQ bool `json:"feature_faq,omitempty"`
|
|
FeatureMemory bool `json:"feature_memory,omitempty"`
|
|
FeatureContactAttributes bool `json:"feature_contact_attributes,omitempty"`
|
|
ProductName string `json:"product_name,omitempty"`
|
|
Instructions string `json:"instructions,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
}
|
|
|
|
func (a *CaptainAssistant) GetConfig() (*AssistantConfig, error) {
|
|
var cfg AssistantConfig
|
|
if len(a.Config) == 0 || string(a.Config) == "{}" || string(a.Config) == "null" {
|
|
return &cfg, nil
|
|
}
|
|
if err := json.Unmarshal(a.Config, &cfg); err != nil {
|
|
// Return an empty config rather than nil so callers that ignore the
|
|
// error do not dereference a nil pointer. The raw JSON may contain
|
|
// type mismatches (e.g. temperature as string) from older data.
|
|
return &cfg, fmt.Errorf("parse assistant config: %w", err)
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(a.Config, &fields); err == nil {
|
|
_, cfg.TemperatureConfigured = fields["temperature"]
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
func (a *CaptainAssistant) SetConfig(cfg *AssistantConfig) error {
|
|
data, err := json.Marshal(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.Config = data
|
|
return nil
|
|
}
|
|
|
|
// GetResponseGuidelines unmarshals the ResponseGuidelines JSON field.
|
|
// Returns nil and no error if the field is empty/null.
|
|
func (a *CaptainAssistant) GetResponseGuidelines() (string, error) {
|
|
if len(a.ResponseGuidelines) == 0 || string(a.ResponseGuidelines) == "{}" || string(a.ResponseGuidelines) == "null" {
|
|
return "", nil
|
|
}
|
|
// ResponseGuidelines may be a plain string or a JSON object
|
|
// Try unmarshaling as string first
|
|
var s string
|
|
if err := json.Unmarshal(a.ResponseGuidelines, &s); err == nil {
|
|
return s, nil
|
|
}
|
|
// Otherwise return the raw JSON as a string
|
|
return string(a.ResponseGuidelines), nil
|
|
}
|
|
|
|
// GetGuardrails unmarshals the Guardrails JSON field.
|
|
// Returns an empty string and no error if the field is empty/null.
|
|
func (a *CaptainAssistant) GetGuardrails() (string, error) {
|
|
if len(a.Guardrails) == 0 || string(a.Guardrails) == "{}" || string(a.Guardrails) == "null" {
|
|
return "", nil
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(a.Guardrails, &s); err == nil {
|
|
return s, nil
|
|
}
|
|
return string(a.Guardrails), nil
|
|
}
|
|
|
|
// --- Captain Document Model ---
|
|
// Reference: Chatwoot enterprise/app/models/captain/document.rb
|
|
// Table: captain_documents
|
|
//
|
|
// Captain Document represents a knowledge base entry (web page or PDF)
|
|
// linked to an Assistant. Contains content, sync tracking, metadata,
|
|
// and content_fingerprint for deduplication.
|
|
|
|
type CaptainDocument struct {
|
|
Base
|
|
AccountID uint `gorm:"index;not null" json:"account_id"`
|
|
AssistantID uint `gorm:"index;not null" json:"assistant_id"`
|
|
Name string `gorm:"size:255" json:"name"`
|
|
ExternalLink string `gorm:"size:2048;not null" json:"external_link"`
|
|
Content string `gorm:"type:text" json:"content,omitempty"`
|
|
ContentFingerprint string `gorm:"size:64" json:"content_fingerprint,omitempty"`
|
|
Status DocumentStatus `gorm:"size:50;default:in_progress;not null" json:"status"`
|
|
SyncStatus DocumentSyncStatus `gorm:"size:50" json:"sync_status,omitempty"`
|
|
LastSyncedAt *int64 `json:"last_synced_at,omitempty"`
|
|
LastSyncAttemptedAt *int64 `json:"last_sync_attempted_at,omitempty"`
|
|
LastSyncErrorCode string `gorm:"size:50" json:"last_sync_error_code,omitempty"`
|
|
Metadata json.RawMessage `gorm:"type:jsonb;serializer:json" json:"metadata,omitempty"`
|
|
|
|
// Relationships
|
|
Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"`
|
|
Responses []CaptainAssistantResponse `gorm:"foreignKey:DocumentableID" json:"responses,omitempty"`
|
|
}
|
|
|
|
func (CaptainDocument) TableName() string { return "captain_documents" }
|
|
|
|
// --- Captain AssistantResponse Model ---
|
|
// Reference: Chatwoot enterprise/app/models/captain/assistant_response.rb
|
|
// Table: captain_assistant_responses
|
|
//
|
|
// FAQ-style knowledge entry with question/answer pair.
|
|
// Has pgvector embedding (1536 dims) for similarity search.
|
|
// Polymorphic documentable association (can belong to Assistant or Document).
|
|
// Status workflow: approved/pending/rejected.
|
|
|
|
type CaptainAssistantResponse struct {
|
|
Base
|
|
AccountID uint `gorm:"index;not null" json:"account_id"`
|
|
AssistantID uint `gorm:"index;not null" json:"assistant_id"`
|
|
DocumentableID *uint `gorm:"index" json:"documentable_id,omitempty"`
|
|
DocumentableType string `gorm:"size:50" json:"documentable_type,omitempty"` // "CaptainAssistant" or "CaptainDocument"
|
|
Question string `gorm:"size:1024;not null" json:"question"`
|
|
Answer string `gorm:"type:text;not null" json:"answer"`
|
|
Status ResponseStatus `gorm:"size:50;default:approved;not null" json:"status"`
|
|
Edited bool `gorm:"default:false;not null" json:"edited"`
|
|
// pgvector-go Vector type for 1536-dimensional embeddings
|
|
Embedding pgvector.Vector `gorm:"type:vector(1536)" json:"embedding,omitempty"`
|
|
|
|
Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"`
|
|
}
|
|
|
|
func (CaptainAssistantResponse) TableName() string { return "captain_assistant_responses" }
|
|
|
|
// --- Captain Scenario Model ---
|
|
// Reference: Chatwoot enterprise/app/models/captain/scenario.rb
|
|
// Table: captain_scenarios
|
|
//
|
|
// Scenario is a specialized agent configuration within an Assistant.
|
|
// It can be enabled/disabled, has its own instruction text,
|
|
// and declares which tools it uses (as JSONB array).
|
|
// Scenario acts as a "handoff" target in the multi-agent system.
|
|
|
|
type CaptainScenario struct {
|
|
Base
|
|
AccountID uint `gorm:"index;not null" json:"account_id"`
|
|
AssistantID uint `gorm:"index;not null" json:"assistant_id"`
|
|
Title string `gorm:"size:255" json:"title"`
|
|
Description string `gorm:"type:text" json:"description,omitempty"`
|
|
Instruction string `gorm:"type:text" json:"instruction,omitempty"`
|
|
Enabled bool `gorm:"default:true;not null;index" json:"enabled"`
|
|
Tools json.RawMessage `gorm:"type:jsonb;serializer:json" json:"tools,omitempty"` // array of tool names
|
|
|
|
Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"`
|
|
}
|
|
|
|
func (CaptainScenario) TableName() string { return "captain_scenarios" }
|
|
|
|
// --- Captain CustomTool Model ---
|
|
// Reference: Chatwoot enterprise/app/models/captain/custom_tool.rb
|
|
// Table: captain_custom_tools
|
|
//
|
|
// User-defined HTTP tool with configurable endpoint, method,
|
|
// auth, request/response templates (Go template equivalent of Liquid),
|
|
// and parameter schema. Max 15 per account. Slug is unique per account.
|
|
|
|
type CaptainCustomTool struct {
|
|
Base
|
|
AccountID uint `gorm:"index;uniqueIndex:idx_captain_custom_tools_account_slug,priority:1;not null" json:"account_id"`
|
|
Title string `gorm:"size:255;not null" json:"title"`
|
|
Slug string `gorm:"size:64;not null;uniqueIndex:idx_captain_custom_tools_account_slug,priority:2" json:"slug"`
|
|
Description string `gorm:"type:text" json:"description,omitempty"`
|
|
EndpointURL string `gorm:"type:text;not null" json:"endpoint_url"`
|
|
HTTPMethod string `gorm:"size:10;default:GET;not null" json:"http_method"`
|
|
AuthType ToolAuthType `gorm:"size:20;default:none" json:"auth_type"`
|
|
AuthConfig json.RawMessage `gorm:"type:jsonb;serializer:json" json:"auth_config,omitempty"`
|
|
ParamSchema json.RawMessage `gorm:"type:jsonb;serializer:json" json:"param_schema,omitempty"`
|
|
RequestTemplate string `gorm:"type:text" json:"request_template,omitempty"`
|
|
ResponseTemplate string `gorm:"type:text" json:"response_template,omitempty"`
|
|
Enabled bool `gorm:"default:true;not null" json:"enabled"`
|
|
}
|
|
|
|
func (CaptainCustomTool) TableName() string { return "captain_custom_tools" }
|
|
|
|
// --- Captain Inbox (join table) ---
|
|
// Reference: Chatwoot CaptainInbox model
|
|
// Table: captain_inboxes
|
|
//
|
|
// Links a Captain Assistant to an Inbox, enabling the assistant
|
|
// to operate on conversations in that inbox.
|
|
|
|
type CaptainInbox struct {
|
|
Base
|
|
AssistantID uint `gorm:"column:captain_assistant_id;index;not null" json:"captain_assistant_id"`
|
|
InboxID uint `gorm:"index;not null" json:"inbox_id"`
|
|
AccountID uint `gorm:"index;not null" json:"account_id"`
|
|
}
|
|
|
|
func (CaptainInbox) TableName() string { return "captain_inboxes" }
|
|
|
|
// --- Captain Preference ---
|
|
// Reference: Chatwoot enterprise/app/models/captain/preference.rb
|
|
// Table: captain_preferences
|
|
//
|
|
// Per-account AI configuration: tone, language, response guidelines,
|
|
// and feature toggles. Each account has at most one preference record.
|
|
|
|
type CaptainPreference struct {
|
|
Base
|
|
AccountID uint `gorm:"uniqueIndex;not null" json:"account_id"`
|
|
Tone string `gorm:"size:50;default:professional" json:"tone"` // professional, friendly, casual, formal
|
|
Language string `gorm:"size:10;default:en" json:"language"` // ISO 639-1 language code
|
|
ResponseGuidelines string `gorm:"type:text" json:"response_guidelines,omitempty"` // free-form instructions for AI responses
|
|
AutoLabelEnabled bool `gorm:"default:false" json:"auto_label_enabled"` // auto-apply label suggestions
|
|
AutoFollowUpEnabled bool `gorm:"default:false" json:"auto_follow_up_enabled"` // auto-create follow-up tasks
|
|
AutoReplyEnabled bool `gorm:"default:false" json:"auto_reply_enabled"` // auto-send assistant responses
|
|
MaxResponseLength int `gorm:"default:500" json:"max_response_length"` // max characters in AI response
|
|
CustomPromptSuffix string `gorm:"type:text" json:"custom_prompt_suffix,omitempty"` // appended to system prompts
|
|
}
|
|
|
|
func (CaptainPreference) TableName() string { return "captain_preferences" }
|