Files
xinmi/internal/application/service/embed_channel.go
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

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)
}