refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
openai "github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
@@ -10,6 +15,7 @@ import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/models"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/tracex"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
)
|
||||
|
||||
@@ -21,13 +27,102 @@ func newOpenAIClient(config models.AIConfig) openai.Client {
|
||||
if config.TimeoutMS > 0 {
|
||||
opts = append(opts, option.WithRequestTimeout(time.Duration(config.TimeoutMS)*time.Millisecond))
|
||||
}
|
||||
if config.HTTPClient != nil {
|
||||
opts = append(opts, option.WithHTTPClient(config.HTTPClient))
|
||||
}
|
||||
if config.MaxRetryCount >= 0 {
|
||||
opts = append(opts, option.WithMaxRetries(config.MaxRetryCount))
|
||||
}
|
||||
|
||||
return openai.NewClient(opts...)
|
||||
}
|
||||
|
||||
type platformAIRequestScopeContextKey struct{}
|
||||
type platformAIRequestPurposeContextKey struct{}
|
||||
|
||||
type platformAIRequestScope struct {
|
||||
base string
|
||||
mu sync.Mutex
|
||||
next map[string]uint64
|
||||
}
|
||||
|
||||
// WithPlatformAIRequestScope binds a persisted business operation identity to
|
||||
// platform AI calls. Recreating a scope with the same base during recovery
|
||||
// reproduces the same purpose/ordinal request IDs, while one live scope gives
|
||||
// every logical upstream call a distinct ordinal.
|
||||
func WithPlatformAIRequestScope(ctx context.Context, base string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, platformAIRequestScopeContextKey{}, &platformAIRequestScope{
|
||||
base: base,
|
||||
next: make(map[string]uint64),
|
||||
})
|
||||
}
|
||||
|
||||
// WithPlatformAIRequestPurpose separates otherwise identical calls belonging
|
||||
// to different stages such as retrieval and document indexing.
|
||||
func WithPlatformAIRequestPurpose(ctx context.Context, purpose string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
purpose = strings.TrimSpace(purpose)
|
||||
if purpose == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, platformAIRequestPurposeContextKey{}, purpose)
|
||||
}
|
||||
|
||||
func ensurePlatformAIRequestScope(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if scope, _ := ctx.Value(platformAIRequestScopeContextKey{}).(*platformAIRequestScope); scope != nil && strings.TrimSpace(scope.base) != "" {
|
||||
return ctx
|
||||
}
|
||||
if requestID := tracex.RequestIDFromContext(ctx); requestID != "" {
|
||||
return WithPlatformAIRequestScope(ctx, "request:"+requestID)
|
||||
}
|
||||
// No durable business identity is available (for example a one-off debug
|
||||
// call), so create one scope for the public operation. Callers with recovery
|
||||
// semantics must bind their persisted identity explicitly.
|
||||
return WithPlatformAIRequestScope(ctx, "operation:"+uuid.NewString())
|
||||
}
|
||||
|
||||
func nextPlatformAIRequestID(ctx context.Context, defaultPurpose string) string {
|
||||
ctx = ensurePlatformAIRequestScope(ctx)
|
||||
scope, _ := ctx.Value(platformAIRequestScopeContextKey{}).(*platformAIRequestScope)
|
||||
purpose, _ := ctx.Value(platformAIRequestPurposeContextKey{}).(string)
|
||||
purpose = strings.TrimSpace(purpose)
|
||||
if purpose == "" {
|
||||
purpose = strings.TrimSpace(defaultPurpose)
|
||||
}
|
||||
if purpose == "" {
|
||||
purpose = "request"
|
||||
}
|
||||
|
||||
scope.mu.Lock()
|
||||
scope.next[purpose]++
|
||||
ordinal := scope.next[purpose]
|
||||
scope.mu.Unlock()
|
||||
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fmt.Sprintf("%s:purpose:%s:call:%d", scope.base, purpose, ordinal))).String()
|
||||
}
|
||||
|
||||
// platformRequestOptions creates one deterministic idempotency key for one
|
||||
// logical upstream call. SDK retries reuse these options. A later call in the
|
||||
// same operation gets the next ordinal; recovery recreates the same sequence.
|
||||
func platformRequestOptions(ctx context.Context, config models.AIConfig, purpose string) []option.RequestOption {
|
||||
if !config.Platform {
|
||||
return nil
|
||||
}
|
||||
return []option.RequestOption{
|
||||
option.WithHeader("X-AI-Request-ID", nextPlatformAIRequestID(ctx, purpose)),
|
||||
}
|
||||
}
|
||||
|
||||
func GetEnabledAIConfig(modelType enums.AIModelType) (*models.AIConfig, error) {
|
||||
item := repositories.AIConfigRepository.GetEnabled(sqls.DB(), modelType)
|
||||
if item == nil {
|
||||
|
||||
Reference in New Issue
Block a user