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
481 lines
16 KiB
Go
481 lines
16 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
)
|
|
|
|
// messageRepository implements the message repository interface
|
|
type messageRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewMessageRepository creates a new message repository
|
|
func NewMessageRepository(db *gorm.DB) interfaces.MessageRepository {
|
|
return &messageRepository{
|
|
db: db,
|
|
}
|
|
}
|
|
|
|
// CreateMessage creates a new message
|
|
func (r *messageRepository) CreateMessage(
|
|
ctx context.Context, message *types.Message,
|
|
) (*types.Message, error) {
|
|
if err := r.db.WithContext(ctx).Create(message).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
// GetMessage retrieves a message
|
|
func (r *messageRepository) GetMessage(
|
|
ctx context.Context, sessionID string, messageID string,
|
|
) (*types.Message, error) {
|
|
var message types.Message
|
|
if err := r.db.WithContext(ctx).Where(
|
|
"id = ? AND session_id = ?", messageID, sessionID,
|
|
).First(&message).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &message, nil
|
|
}
|
|
|
|
// GetMessagesBySession retrieves all messages for a session with pagination
|
|
func (r *messageRepository) GetMessagesBySession(
|
|
ctx context.Context, sessionID string, page int, pageSize int,
|
|
) ([]*types.Message, error) {
|
|
var messages []*types.Message
|
|
if err := r.db.WithContext(ctx).Where("session_id = ?", sessionID).Order("created_at ASC").
|
|
Offset((page - 1) * pageSize).Limit(pageSize).Find(&messages).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return messages, nil
|
|
}
|
|
|
|
// GetRecentMessagesBySession retrieves recent messages for a session
|
|
func (r *messageRepository) GetRecentMessagesBySession(
|
|
ctx context.Context, sessionID string, limit int,
|
|
) ([]*types.Message, error) {
|
|
var messages []*types.Message
|
|
if err := r.db.WithContext(ctx).Where(
|
|
"session_id = ?", sessionID,
|
|
).Order("created_at DESC").Limit(limit).Find(&messages).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
slices.SortFunc(messages, func(a, b *types.Message) int {
|
|
cmp := a.CreatedAt.Compare(b.CreatedAt)
|
|
if cmp == 0 {
|
|
if a.Role == "user" { // User messages come first
|
|
return -1
|
|
}
|
|
return 1 // Assistant messages come last
|
|
}
|
|
return cmp
|
|
})
|
|
return messages, nil
|
|
}
|
|
|
|
// GetMessagesBySessionBeforeTime retrieves messages from a session created before a specific time
|
|
func (r *messageRepository) GetMessagesBySessionBeforeTime(
|
|
ctx context.Context, sessionID string, beforeTime time.Time, limit int,
|
|
) ([]*types.Message, error) {
|
|
var messages []*types.Message
|
|
if err := r.db.WithContext(ctx).Where(
|
|
"session_id = ? AND created_at < ?", sessionID, beforeTime,
|
|
).Order("created_at DESC").Limit(limit).Find(&messages).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
slices.SortFunc(messages, func(a, b *types.Message) int {
|
|
cmp := a.CreatedAt.Compare(b.CreatedAt)
|
|
if cmp == 0 {
|
|
if a.Role == "user" { // User messages come first
|
|
return -1
|
|
}
|
|
return 1 // Assistant messages come last
|
|
}
|
|
return cmp
|
|
})
|
|
return messages, nil
|
|
}
|
|
|
|
// ListMessagesBySessionAfterTime returns the oldest messages created after
|
|
// afterTime, so a caller holding a watermark can walk a session forward
|
|
// without skipping anything when it has more new messages than one page.
|
|
func (r *messageRepository) ListMessagesBySessionAfterTime(
|
|
ctx context.Context, sessionID string, afterTime time.Time, limit int,
|
|
) ([]*types.Message, error) {
|
|
var messages []*types.Message
|
|
query := r.db.WithContext(ctx).Where("session_id = ?", sessionID)
|
|
if !afterTime.IsZero() {
|
|
query = query.Where("created_at > ?", afterTime)
|
|
}
|
|
if err := query.Order("created_at ASC, id ASC").Limit(limit).Find(&messages).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
// Memory extraction checkpoints by timestamp. Include the rest of the
|
|
// boundary timestamp so advancing that checkpoint cannot skip tied rows.
|
|
if limit > 0 && len(messages) == limit {
|
|
last := messages[len(messages)-1]
|
|
var tied []*types.Message
|
|
if err := r.db.WithContext(ctx).Where("session_id = ? AND created_at = ? AND id > ?", sessionID, last.CreatedAt, last.ID).
|
|
Order("id ASC").Find(&tied).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
messages = append(messages, tied...)
|
|
}
|
|
return messages, nil
|
|
}
|
|
|
|
func (r *messageRepository) MemoryOwnedSessionIDs(
|
|
ctx context.Context, scope interfaces.MemoryScope, sessionIDs []string,
|
|
) (map[string]bool, error) {
|
|
owned := map[string]bool{}
|
|
if !scope.Valid() || len(sessionIDs) == 0 {
|
|
return owned, nil
|
|
}
|
|
query := r.db.WithContext(ctx).Table("sessions").Select("sessions.id").
|
|
Where("sessions.tenant_id = ? AND sessions.deleted_at IS NULL AND sessions.id IN ?", scope.TenantID, sessionIDs)
|
|
parts := strings.SplitN(scope.SubjectID, ":", 2)
|
|
if len(parts) != 2 || parts[1] == "" {
|
|
return owned, nil
|
|
}
|
|
switch parts[0] {
|
|
case types.PrincipalWebUser:
|
|
query = query.Where("sessions.user_id = ?", parts[1]).Where(`EXISTS (
|
|
SELECT 1 FROM users AS memory_user
|
|
WHERE memory_user.id = sessions.user_id AND memory_user.is_active = ?
|
|
AND memory_user.deleted_at IS NULL
|
|
AND (memory_user.is_system_admin = ? OR EXISTS (
|
|
SELECT 1 FROM tenant_members AS memory_member
|
|
WHERE memory_member.user_id = memory_user.id AND memory_member.tenant_id = sessions.tenant_id
|
|
AND memory_member.status = ? AND memory_member.deleted_at IS NULL)))`,
|
|
true, true, types.TenantMemberStatusActive)
|
|
case types.PrincipalAPIExternalUser, types.PrincipalEmbedSession:
|
|
query = query.Where("sessions.user_id = ?", scope.SubjectID)
|
|
case types.PrincipalIMUser:
|
|
identity := strings.SplitN(parts[1], ":", 4)
|
|
if len(identity) != 4 || identity[0] != strconv.FormatUint(scope.TenantID, 10) {
|
|
return owned, nil
|
|
}
|
|
// Shared IM threads do not retain the author on individual messages.
|
|
// They cannot safely become one participant's personal memory.
|
|
query = query.Where(`EXISTS (SELECT 1 FROM im_channel_sessions AS im
|
|
WHERE im.session_id = sessions.id AND im.tenant_id = ? AND im.im_channel_id = ?
|
|
AND im.platform = ? AND im.user_id = ? AND im.thread_id = '' AND im.deleted_at IS NULL)`,
|
|
scope.TenantID, identity[1], identity[2], identity[3])
|
|
default:
|
|
return owned, nil
|
|
}
|
|
var ids []string
|
|
if err := query.Pluck("sessions.id", &ids).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range ids {
|
|
owned[id] = true
|
|
}
|
|
return owned, nil
|
|
}
|
|
|
|
func (r *messageRepository) MemoryTurnStart(
|
|
ctx context.Context, scope interfaces.MemoryScope, sessionID, messageID string,
|
|
) (time.Time, error) {
|
|
owned, err := r.MemoryOwnedSessionIDs(ctx, scope, []string{sessionID})
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
if !owned[sessionID] {
|
|
return time.Time{}, gorm.ErrRecordNotFound
|
|
}
|
|
message, err := r.GetMessage(ctx, sessionID, messageID)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
if message.Role == "user" {
|
|
return message.CreatedAt, nil
|
|
}
|
|
var input types.Message
|
|
query := r.db.WithContext(ctx).Where("session_id = ? AND role = ?", sessionID, "user")
|
|
if message.RequestID != "" {
|
|
query = query.Where("request_id = ?", message.RequestID)
|
|
} else {
|
|
query = query.Where("created_at <= ?", message.CreatedAt)
|
|
}
|
|
if err := query.Order("created_at DESC, id DESC").First(&input).Error; err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
return input.CreatedAt, nil
|
|
}
|
|
|
|
// UpdateMessage updates an existing message
|
|
func (r *messageRepository) UpdateMessage(ctx context.Context, message *types.Message) error {
|
|
return r.db.WithContext(ctx).Model(&types.Message{}).Where(
|
|
"id = ? AND session_id = ?", message.ID, message.SessionID,
|
|
).Updates(message).Error
|
|
}
|
|
|
|
// DeleteMessage deletes a message
|
|
func (r *messageRepository) DeleteMessage(ctx context.Context, sessionID string, messageID string) error {
|
|
return r.db.WithContext(ctx).Where(
|
|
"id = ? AND session_id = ?", messageID, sessionID,
|
|
).Delete(&types.Message{}).Error
|
|
}
|
|
|
|
// GetFirstMessageOfUser retrieves the first message from a user in a session
|
|
func (r *messageRepository) GetFirstMessageOfUser(ctx context.Context, sessionID string) (*types.Message, error) {
|
|
var message types.Message
|
|
if err := r.db.WithContext(ctx).Where(
|
|
"session_id = ? and role = ?", sessionID, "user",
|
|
).Order("created_at ASC").First(&message).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &message, nil
|
|
}
|
|
|
|
// GetMessageByRequestID retrieves a message by request ID
|
|
func (r *messageRepository) GetMessageByRequestID(
|
|
ctx context.Context, sessionID string, requestID string,
|
|
) (*types.Message, error) {
|
|
var message types.Message
|
|
|
|
result := r.db.WithContext(ctx).
|
|
Where("session_id = ? AND request_id = ?", sessionID, requestID).
|
|
First(&message)
|
|
|
|
if result.Error != nil {
|
|
if result.Error == gorm.ErrRecordNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, result.Error
|
|
}
|
|
|
|
return &message, nil
|
|
}
|
|
|
|
// SearchMessagesByKeyword searches messages by keyword (ILIKE) across sessions for a tenant
|
|
func (r *messageRepository) SearchMessagesByKeyword(
|
|
ctx context.Context, tenantID uint64, ownerID, keyword string, sessionIDs []string, limit int,
|
|
) ([]*types.MessageWithSession, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
var results []*types.MessageWithSession
|
|
|
|
query := r.db.WithContext(ctx).
|
|
Table("messages").
|
|
Select("messages.*, sessions.title as session_title").
|
|
Joins("INNER JOIN sessions ON sessions.id = messages.session_id AND sessions.deleted_at IS NULL").
|
|
Where("sessions.tenant_id = ?", tenantID).
|
|
Where("messages.deleted_at IS NULL").
|
|
Where("messages.content ILIKE ?", "%"+escapeLikeKeyword(keyword)+"%")
|
|
|
|
// Matches the scoping used when listing sessions, including the legacy
|
|
// allowance for tenant-level sessions created before per-user ownership.
|
|
if ownerID != "" {
|
|
query = query.Where("(sessions.user_id = ? OR sessions.user_id IS NULL OR sessions.user_id = '')", ownerID)
|
|
}
|
|
|
|
if len(sessionIDs) > 0 {
|
|
query = query.Where("messages.session_id IN ?", sessionIDs)
|
|
}
|
|
|
|
if err := query.Order("messages.created_at DESC").Limit(limit).Find(&results).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// OwnedSessionIDs narrows a set of session ids to the ones this person owns.
|
|
//
|
|
// The vector path finds messages through a shared knowledge base that has no
|
|
// notion of who wrote them, so ownership has to be re-established here before
|
|
// anything is returned.
|
|
func (r *messageRepository) OwnedSessionIDs(
|
|
ctx context.Context, tenantID uint64, ownerID string, sessionIDs []string,
|
|
) (map[string]bool, error) {
|
|
owned := make(map[string]bool, len(sessionIDs))
|
|
if len(sessionIDs) == 0 {
|
|
return owned, nil
|
|
}
|
|
var ids []string
|
|
query := r.db.WithContext(ctx).
|
|
Table("sessions").
|
|
Select("id").
|
|
Where("tenant_id = ?", tenantID).
|
|
Where("deleted_at IS NULL").
|
|
Where("id IN ?", sessionIDs)
|
|
if ownerID != "" {
|
|
query = query.Where("(user_id = ? OR user_id IS NULL OR user_id = '')", ownerID)
|
|
}
|
|
if err := query.Pluck("id", &ids).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range ids {
|
|
owned[id] = true
|
|
}
|
|
return owned, nil
|
|
}
|
|
|
|
// GetMessagesByKnowledgeIDs retrieves messages by their associated Knowledge IDs
|
|
func (r *messageRepository) GetMessagesByKnowledgeIDs(
|
|
ctx context.Context, knowledgeIDs []string,
|
|
) ([]*types.MessageWithSession, error) {
|
|
if len(knowledgeIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
var results []*types.MessageWithSession
|
|
if err := r.db.WithContext(ctx).
|
|
Table("messages").
|
|
Select("messages.*, sessions.title as session_title").
|
|
Joins("INNER JOIN sessions ON sessions.id = messages.session_id AND sessions.deleted_at IS NULL").
|
|
Where("messages.deleted_at IS NULL").
|
|
Where("messages.knowledge_id IN ?", knowledgeIDs).
|
|
Find(&results).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// GetMessagesByRequestIDs retrieves messages by their request IDs (used to fetch Q&A pair partners)
|
|
func (r *messageRepository) GetMessagesByRequestIDs(
|
|
ctx context.Context, requestIDs []string,
|
|
) ([]*types.MessageWithSession, error) {
|
|
if len(requestIDs) == 0 {
|
|
return nil, nil
|
|
}
|
|
var results []*types.MessageWithSession
|
|
if err := r.db.WithContext(ctx).
|
|
Table("messages").
|
|
Select("messages.*, sessions.title as session_title").
|
|
Joins("INNER JOIN sessions ON sessions.id = messages.session_id AND sessions.deleted_at IS NULL").
|
|
Where("messages.deleted_at IS NULL").
|
|
Where("messages.request_id IN ?", requestIDs).
|
|
Find(&results).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// GetKnowledgeIDsBySessionID retrieves all knowledge IDs for messages in a session
|
|
func (r *messageRepository) GetKnowledgeIDsBySessionID(
|
|
ctx context.Context, sessionID string,
|
|
) ([]string, error) {
|
|
var knowledgeIDs []string
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&types.Message{}).
|
|
Where("session_id = ? AND knowledge_id != '' AND knowledge_id IS NOT NULL AND deleted_at IS NULL", sessionID).
|
|
Pluck("knowledge_id", &knowledgeIDs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return knowledgeIDs, nil
|
|
}
|
|
|
|
// UpdateMessageImages updates only the images JSONB column for a message.
|
|
// Uses Select to force GORM to include the column even when struct-based
|
|
// Updates would otherwise skip custom Valuer types.
|
|
func (r *messageRepository) UpdateMessageImages(ctx context.Context, sessionID, messageID string, images types.MessageImages) error {
|
|
return r.db.WithContext(ctx).
|
|
Model(&types.Message{}).
|
|
Where("id = ? AND session_id = ?", messageID, sessionID).
|
|
Update("images", images).Error
|
|
}
|
|
|
|
// UpdateMessageRenderedContent updates only the rendered_content column for a message.
|
|
func (r *messageRepository) UpdateMessageRenderedContent(ctx context.Context, sessionID, messageID string, renderedContent string) error {
|
|
return r.db.WithContext(ctx).
|
|
Model(&types.Message{}).
|
|
Where("id = ? AND session_id = ?", messageID, sessionID).
|
|
Update("rendered_content", renderedContent).Error
|
|
}
|
|
|
|
// DeleteMessagesBySessionID deletes all messages belonging to a session (soft delete)
|
|
func (r *messageRepository) DeleteMessagesBySessionID(ctx context.Context, sessionID string) error {
|
|
return r.db.WithContext(ctx).Where("session_id = ?", sessionID).Delete(&types.Message{}).Error
|
|
}
|
|
|
|
// UpdateMessageKnowledgeID updates the knowledge_id field for a message
|
|
func (r *messageRepository) UpdateMessageKnowledgeID(
|
|
ctx context.Context, messageID string, knowledgeID string,
|
|
) error {
|
|
return r.db.WithContext(ctx).
|
|
Model(&types.Message{}).
|
|
Where("id = ?", messageID).
|
|
Update("knowledge_id", knowledgeID).Error
|
|
}
|
|
|
|
// GetSessionArtifacts returns every skill-produced MessageArtifact recorded
|
|
// against any assistant message of the session, in creation order.
|
|
//
|
|
// Projection is scoped to the artifacts JSONB column plus created_at (used
|
|
// to order the flattened output). Assistant messages without artifacts (the
|
|
// common case) contribute an empty slice and cost nothing extra.
|
|
func (r *messageRepository) GetSessionArtifacts(
|
|
ctx context.Context, sessionID string,
|
|
) (types.MessageArtifacts, error) {
|
|
if sessionID == "" {
|
|
return nil, nil
|
|
}
|
|
var rows []struct {
|
|
Artifacts types.MessageArtifacts `gorm:"column:artifacts"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
}
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&types.Message{}).
|
|
Select("artifacts", "created_at").
|
|
Where("session_id = ? AND deleted_at IS NULL", sessionID).
|
|
Order("created_at ASC").
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return types.MessageArtifacts{}, nil
|
|
}
|
|
result := make(types.MessageArtifacts, 0, len(rows))
|
|
for _, row := range rows {
|
|
if len(row.Artifacts) == 0 {
|
|
continue
|
|
}
|
|
result = append(result, row.Artifacts...)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetSessionAttachments returns every user-uploaded attachment in creation
|
|
// order while projecting only the attachments JSON column.
|
|
func (r *messageRepository) GetSessionAttachments(
|
|
ctx context.Context, sessionID string,
|
|
) (types.MessageAttachments, error) {
|
|
if sessionID == "" {
|
|
return nil, nil
|
|
}
|
|
var rows []struct {
|
|
Attachments types.MessageAttachments `gorm:"column:attachments"`
|
|
CreatedAt time.Time `gorm:"column:created_at"`
|
|
}
|
|
if err := r.db.WithContext(ctx).
|
|
Model(&types.Message{}).
|
|
Select("attachments", "created_at").
|
|
Where("session_id = ? AND deleted_at IS NULL", sessionID).
|
|
Order("created_at ASC").
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
result := make(types.MessageAttachments, 0, len(rows))
|
|
for _, row := range rows {
|
|
result = append(result, row.Attachments...)
|
|
}
|
|
return result, nil
|
|
}
|