Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
419 lines
13 KiB
Go
419 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
const embedTokenBytes = 32
|
|
|
|
var (
|
|
ErrEmbedChannelNotFound = errors.New("embed channel not found")
|
|
ErrEmbedTokenInvalid = errors.New("embed publish token is invalid")
|
|
ErrEmbedChannelDisabled = errors.New("embed channel is disabled")
|
|
ErrEmbedChunkNotFound = errors.New("embed chunk not found")
|
|
ErrEmbedChunkForbidden = errors.New("embed chunk not accessible")
|
|
)
|
|
|
|
type embedChannelService struct {
|
|
repo interfaces.EmbedChannelRepository
|
|
agentService interfaces.CustomAgentService
|
|
chunkService interfaces.ChunkService
|
|
redis *redis.Client
|
|
}
|
|
|
|
func NewEmbedChannelService(
|
|
repo interfaces.EmbedChannelRepository,
|
|
agentService interfaces.CustomAgentService,
|
|
chunkService interfaces.ChunkService,
|
|
redisClient *redis.Client,
|
|
) interfaces.EmbedChannelService {
|
|
return &embedChannelService{
|
|
repo: repo,
|
|
agentService: agentService,
|
|
chunkService: chunkService,
|
|
redis: redisClient,
|
|
}
|
|
}
|
|
|
|
func generateEmbedPublishToken() (string, error) {
|
|
buf := make([]byte, embedTokenBytes)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return "em_" + base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
func (s *embedChannelService) Create(
|
|
ctx context.Context, tenantID uint64, agentID string, req *types.EmbedChannel,
|
|
) (*types.EmbedChannel, string, error) {
|
|
agentID = strings.TrimSpace(agentID)
|
|
if _, err := s.ensureAgentOwned(ctx, tenantID, agentID); err != nil {
|
|
return nil, "", err
|
|
}
|
|
token, err := generateEmbedPublishToken()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
originsJSON := req.AllowedOrigins
|
|
if len(originsJSON) == 0 {
|
|
originsJSON = []byte("[]")
|
|
}
|
|
ch := &types.EmbedChannel{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
Name: strings.TrimSpace(req.Name),
|
|
Enabled: req.Enabled,
|
|
PublishToken: token,
|
|
AllowedOrigins: originsJSON,
|
|
WelcomeMessage: req.WelcomeMessage,
|
|
RateLimitPerMinute: req.RateLimitPerMinute,
|
|
RateLimitPerDay: req.RateLimitPerDay,
|
|
PrimaryColor: strings.TrimSpace(req.PrimaryColor),
|
|
PageTitle: strings.TrimSpace(req.PageTitle),
|
|
HeaderTitleMode: types.NormalizeEmbedHeaderTitleMode(req.HeaderTitleMode),
|
|
ShowSuggestedQuestions: req.ShowSuggestedQuestions,
|
|
WidgetPosition: types.NormalizeEmbedWidgetPosition(req.WidgetPosition),
|
|
AllowWebSearch: req.AllowWebSearch,
|
|
AllowFileUpload: req.AllowFileUpload,
|
|
DefaultLocale: types.NormalizeEmbedDefaultLocale(req.DefaultLocale),
|
|
}
|
|
if ch.RateLimitPerMinute <= 0 {
|
|
ch.RateLimitPerMinute = 30
|
|
}
|
|
if ch.RateLimitPerDay <= 0 {
|
|
ch.RateLimitPerDay = types.DefaultEmbedRateLimitPerDay
|
|
}
|
|
if err := s.repo.Create(ctx, ch); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return ch, token, nil
|
|
}
|
|
|
|
func (s *embedChannelService) ListByAgent(
|
|
ctx context.Context, tenantID uint64, agentID string,
|
|
) ([]*types.EmbedChannel, error) {
|
|
agentID = strings.TrimSpace(agentID)
|
|
if _, err := s.ensureAgentOwned(ctx, tenantID, agentID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.ListByAgent(ctx, tenantID, agentID)
|
|
}
|
|
|
|
func (s *embedChannelService) ListByTenant(
|
|
ctx context.Context, tenantID uint64,
|
|
) ([]*types.EmbedChannel, error) {
|
|
channels, err := s.repo.ListByTenant(ctx, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
visible := make([]*types.EmbedChannel, 0, len(channels))
|
|
for _, channel := range channels {
|
|
if _, err := s.ensureAgentOwned(ctx, tenantID, channel.AgentID); err == nil {
|
|
visible = append(visible, channel)
|
|
}
|
|
}
|
|
return visible, nil
|
|
}
|
|
|
|
func (s *embedChannelService) Update(
|
|
ctx context.Context, tenantID uint64, id string, req *types.EmbedChannel,
|
|
enabled *bool, showSuggested *bool, allowWebSearch *bool, allowFileUpload *bool,
|
|
defaultLocale *string, webhookURL *string, webhookSecret *string,
|
|
) (*types.EmbedChannel, error) {
|
|
ch, err := s.getOwned(ctx, tenantID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Name != "" {
|
|
ch.Name = strings.TrimSpace(req.Name)
|
|
}
|
|
ch.WelcomeMessage = req.WelcomeMessage
|
|
ch.PrimaryColor = strings.TrimSpace(req.PrimaryColor)
|
|
ch.PageTitle = strings.TrimSpace(req.PageTitle)
|
|
ch.HeaderTitleMode = types.NormalizeEmbedHeaderTitleMode(req.HeaderTitleMode)
|
|
if showSuggested != nil {
|
|
ch.ShowSuggestedQuestions = *showSuggested
|
|
}
|
|
if allowWebSearch != nil {
|
|
ch.AllowWebSearch = *allowWebSearch
|
|
}
|
|
if allowFileUpload != nil {
|
|
ch.AllowFileUpload = *allowFileUpload
|
|
}
|
|
if defaultLocale != nil {
|
|
ch.DefaultLocale = types.NormalizeEmbedDefaultLocale(*defaultLocale)
|
|
}
|
|
if webhookURL != nil {
|
|
trimmed := strings.TrimSpace(*webhookURL)
|
|
if err := ValidateEmbedWebhookURL(trimmed); err != nil {
|
|
return nil, err
|
|
}
|
|
ch.WebhookURL = trimmed
|
|
}
|
|
if webhookSecret != nil {
|
|
ch.WebhookSecret = strings.TrimSpace(*webhookSecret)
|
|
}
|
|
if req.WidgetPosition != "" {
|
|
ch.WidgetPosition = types.NormalizeEmbedWidgetPosition(req.WidgetPosition)
|
|
}
|
|
if enabled != nil {
|
|
ch.Enabled = *enabled
|
|
}
|
|
if req.RateLimitPerMinute > 0 {
|
|
ch.RateLimitPerMinute = req.RateLimitPerMinute
|
|
}
|
|
if req.RateLimitPerDay > 0 {
|
|
ch.RateLimitPerDay = req.RateLimitPerDay
|
|
}
|
|
if req.AllowedOrigins != nil {
|
|
if len(req.AllowedOrigins) == 0 {
|
|
ch.AllowedOrigins = []byte("[]")
|
|
} else {
|
|
ch.AllowedOrigins = req.AllowedOrigins
|
|
}
|
|
}
|
|
if trimmed := strings.TrimSpace(req.AgentID); trimmed != "" && trimmed != ch.AgentID {
|
|
if _, err := s.ensureAgentOwned(ctx, tenantID, trimmed); err != nil {
|
|
return nil, err
|
|
}
|
|
ch.AgentID = trimmed
|
|
}
|
|
if err := s.repo.Update(ctx, ch); err != nil {
|
|
return nil, err
|
|
}
|
|
return ch, nil
|
|
}
|
|
|
|
func (s *embedChannelService) Delete(ctx context.Context, tenantID uint64, id string) error {
|
|
if _, err := s.getOwned(ctx, tenantID, id); err != nil {
|
|
return err
|
|
}
|
|
return s.repo.Delete(ctx, tenantID, id)
|
|
}
|
|
|
|
func (s *embedChannelService) RotateToken(
|
|
ctx context.Context, tenantID uint64, id string,
|
|
) (*types.EmbedChannel, string, error) {
|
|
ch, err := s.getOwned(ctx, tenantID, id)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
token, err := generateEmbedPublishToken()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
ch.PublishToken = token
|
|
if err := s.repo.Update(ctx, ch); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return ch, token, nil
|
|
}
|
|
|
|
func (s *embedChannelService) LookupForEmbed(
|
|
ctx context.Context, channelID, token string,
|
|
) (*types.EmbedChannel, error) {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return nil, ErrEmbedTokenInvalid
|
|
}
|
|
ch, err := s.repo.GetByID(ctx, channelID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ch == nil {
|
|
return nil, ErrEmbedTokenInvalid
|
|
}
|
|
if !ch.Enabled {
|
|
return nil, ErrEmbedChannelDisabled
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(ch.PublishToken), []byte(token)) != 1 {
|
|
return nil, ErrEmbedTokenInvalid
|
|
}
|
|
return ch, nil
|
|
}
|
|
|
|
func (s *embedChannelService) PublicConfig(ctx context.Context, ch *types.EmbedChannel) types.EmbedChannelPublicConfig {
|
|
kbIDs := s.resolveKnowledgeBaseIDs(ctx, ch)
|
|
displayTitle, agentName, agentAvatar := s.resolveDisplayMeta(ctx, ch)
|
|
agentWebSearchEnabled := false
|
|
agentImageUploadEnabled := false
|
|
if agent, err := s.agentService.GetAgentByID(ctx, ch.AgentID); err == nil && agent != nil {
|
|
agentWebSearchEnabled = agent.Config.WebSearchEnabled
|
|
agentImageUploadEnabled = agent.Config.ImageUploadEnabled
|
|
}
|
|
return types.EmbedChannelPublicConfig{
|
|
ChannelID: ch.ID,
|
|
Name: ch.Name,
|
|
DisplayTitle: displayTitle,
|
|
KnowledgeBaseIDs: kbIDs,
|
|
AgentID: ch.AgentID,
|
|
AgentName: agentName,
|
|
AgentAvatar: agentAvatar,
|
|
WelcomeMessage: ch.WelcomeMessage,
|
|
PrimaryColor: ch.PrimaryColor,
|
|
PageTitle: ch.PageTitle,
|
|
HeaderTitleMode: types.NormalizeEmbedHeaderTitleMode(ch.HeaderTitleMode),
|
|
ShowSuggestedQuestions: ch.ShowSuggestedQuestions,
|
|
AllowedOrigins: ch.AllowedOriginsList(),
|
|
WidgetPosition: types.NormalizeEmbedWidgetPosition(ch.WidgetPosition),
|
|
AllowWebSearch: ch.AllowWebSearch,
|
|
AllowFileUpload: ch.AllowFileUpload,
|
|
AgentWebSearchEnabled: agentWebSearchEnabled,
|
|
AgentImageUploadEnabled: agentImageUploadEnabled,
|
|
DefaultLocale: types.NormalizeEmbedDefaultLocale(ch.DefaultLocale),
|
|
}
|
|
}
|
|
|
|
func (s *embedChannelService) EmbedChunk(
|
|
ctx context.Context, ch *types.EmbedChannel, chunkID string,
|
|
) (*types.Chunk, error) {
|
|
chunkID = strings.TrimSpace(chunkID)
|
|
if chunkID == "" {
|
|
return nil, ErrEmbedChunkNotFound
|
|
}
|
|
chunk, err := s.chunkService.GetChunkByIDOnly(ctx, chunkID)
|
|
if err != nil || chunk == nil {
|
|
return nil, ErrEmbedChunkNotFound
|
|
}
|
|
if !s.chunkAllowedForEmbed(ctx, ch, chunk) {
|
|
return nil, ErrEmbedChunkForbidden
|
|
}
|
|
return chunk, nil
|
|
}
|
|
|
|
func (s *embedChannelService) chunkAllowedForEmbed(
|
|
ctx context.Context, ch *types.EmbedChannel, chunk *types.Chunk,
|
|
) bool {
|
|
if chunk == nil || chunk.KnowledgeBaseID == "" {
|
|
return false
|
|
}
|
|
// 显式重校验空间:GetChunkByIDOnly 无空间过滤,且 KBSelectionMode=="all"/默认
|
|
// 分支会无条件放行,必须在此挡住跨空间 chunk id 撞库读取他人知识库正文。
|
|
if ch == nil || chunk.TenantID != ch.TenantID {
|
|
return false
|
|
}
|
|
allowedKBs := s.resolveKnowledgeBaseIDs(ctx, ch)
|
|
if len(allowedKBs) > 0 {
|
|
for _, kbID := range allowedKBs {
|
|
if kbID == chunk.KnowledgeBaseID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
agent, err := s.agentService.GetAgentByID(ctx, ch.AgentID)
|
|
if err != nil || agent == nil {
|
|
return false
|
|
}
|
|
switch agent.Config.KBSelectionMode {
|
|
case "none":
|
|
return false
|
|
case "selected":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (s *embedChannelService) SuggestedQuestions(
|
|
ctx context.Context, ch *types.EmbedChannel, limit int,
|
|
) ([]types.SuggestedQuestion, error) {
|
|
if ch == nil || !ch.ShowSuggestedQuestions {
|
|
return nil, nil
|
|
}
|
|
// A non-positive limit is forwarded as "unspecified" so GetSuggestedQuestions
|
|
// falls back to the agent's configured starter count rather than a fixed 6.
|
|
kbIDs := s.resolveKnowledgeBaseIDs(ctx, ch)
|
|
return s.agentService.GetSuggestedQuestions(ctx, ch.AgentID, kbIDs, nil, nil, limit)
|
|
}
|
|
|
|
// EmbedDisplayTitle resolves the human-readable title for embed sessions and UI chrome.
|
|
func (s *embedChannelService) EmbedDisplayTitle(ctx context.Context, ch *types.EmbedChannel) string {
|
|
title, _, _ := s.resolveDisplayMeta(ctx, ch)
|
|
return title
|
|
}
|
|
|
|
func (s *embedChannelService) resolveDisplayMeta(
|
|
ctx context.Context, ch *types.EmbedChannel,
|
|
) (displayTitle, agentName, agentAvatar string) {
|
|
if pageTitle := strings.TrimSpace(ch.PageTitle); pageTitle != "" {
|
|
displayTitle = pageTitle
|
|
} else if name := strings.TrimSpace(ch.Name); name != "" {
|
|
displayTitle = name
|
|
}
|
|
agent, err := s.agentService.GetAgentByID(ctx, ch.AgentID)
|
|
if err == nil && agent != nil {
|
|
agentName = strings.TrimSpace(agent.Name)
|
|
agentAvatar = strings.TrimSpace(agent.Avatar)
|
|
if displayTitle == "" && agentName != "" {
|
|
displayTitle = agentName
|
|
}
|
|
}
|
|
if displayTitle == "" {
|
|
displayTitle = "AI Assistant"
|
|
}
|
|
return displayTitle, agentName, agentAvatar
|
|
}
|
|
|
|
func (s *embedChannelService) resolveKnowledgeBaseIDs(ctx context.Context, ch *types.EmbedChannel) []string {
|
|
agent, err := s.agentService.GetAgentByID(ctx, ch.AgentID)
|
|
if err == nil && agent != nil && agent.Config.KBSelectionMode == "selected" {
|
|
return append([]string(nil), agent.Config.KnowledgeBases...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *embedChannelService) ensureAgentOwned(ctx context.Context, tenantID uint64, agentID string) (*types.CustomAgent, error) {
|
|
agentID = strings.TrimSpace(agentID)
|
|
if agentID == "" {
|
|
return nil, apperrors.NewBadRequestError("agent_id is required")
|
|
}
|
|
agent, err := s.agentService.GetAgentByID(ctx, agentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if agent == nil || agent.TenantID != tenantID {
|
|
return nil, apperrors.NewNotFoundError("agent not found")
|
|
}
|
|
if !assetOwnedByCaller(ctx, tenantID, agent.CreatedBy) {
|
|
return nil, apperrors.NewForbiddenError("only the Agent owner or administrator can manage its embed channels")
|
|
}
|
|
return agent, nil
|
|
}
|
|
|
|
func (s *embedChannelService) GetOwnedChannel(
|
|
ctx context.Context, tenantID uint64, id string,
|
|
) (*types.EmbedChannel, error) {
|
|
return s.getOwned(ctx, tenantID, id)
|
|
}
|
|
|
|
func (s *embedChannelService) getOwned(ctx context.Context, tenantID uint64, id string) (*types.EmbedChannel, error) {
|
|
ch, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ch == nil || ch.TenantID != tenantID {
|
|
return nil, ErrEmbedChannelNotFound
|
|
}
|
|
if _, err := s.ensureAgentOwned(ctx, tenantID, ch.AgentID); err != nil {
|
|
return nil, ErrEmbedChannelNotFound
|
|
}
|
|
return ch, nil
|
|
}
|
|
|
|
// EmbedSessionDescription returns the marker stored on embed-created sessions.
|
|
func EmbedSessionDescription(channelID string) string {
|
|
return fmt.Sprintf("%s%s", types.EmbedSessionMarkerPrefix, channelID)
|
|
}
|