Add attachment and knowledge management features in neo package

- Implemented SaveAttachment, DeleteAttachment, GetAttachments, and GetAttachment methods for managing file attachments.
- Introduced SaveKnowledge, DeleteKnowledge, GetKnowledges, and GetKnowledge methods for handling knowledge collections.
- Enhanced the Store interface to include methods for attachment and knowledge management.
- Updated tests to cover new attachment and knowledge functionalities, ensuring robust validation and error handling.
This commit is contained in:
Max 2025-06-01 16:06:41 +08:00
parent 2222bbfcd7
commit 49562b8be0
7 changed files with 2176 additions and 12 deletions

View file

@ -396,3 +396,50 @@ func (m *mockStore) DeleteAssistants(filter store.AssistantFilter) (int64, error
func (m *mockStore) GetAssistantTags(locale ...string) ([]store.Tag, error) {
return []store.Tag{}, nil
}
// Attachment related methods
func (m *mockStore) SaveAttachment(attachment map[string]interface{}) (interface{}, error) {
return attachment["file_id"], nil
}
func (m *mockStore) DeleteAttachment(fileID string) error {
return nil
}
func (m *mockStore) GetAttachments(filter store.AttachmentFilter, locale ...string) (*store.AttachmentResponse, error) {
return &store.AttachmentResponse{}, nil
}
func (m *mockStore) GetAttachment(fileID string, locale ...string) (map[string]interface{}, error) {
return nil, nil
}
func (m *mockStore) DeleteAttachments(filter store.AttachmentFilter) (int64, error) {
return 0, nil
}
// Knowledge related methods
func (m *mockStore) SaveKnowledge(knowledge map[string]interface{}) (interface{}, error) {
return knowledge["collection_id"], nil
}
func (m *mockStore) DeleteKnowledge(collectionID string) error {
return nil
}
func (m *mockStore) GetKnowledges(filter store.KnowledgeFilter, locale ...string) (*store.KnowledgeResponse, error) {
return &store.KnowledgeResponse{}, nil
}
func (m *mockStore) GetKnowledge(collectionID string, locale ...string) (map[string]interface{}, error) {
return nil, nil
}
func (m *mockStore) DeleteKnowledges(filter store.KnowledgeFilter) (int64, error) {
return 0, nil
}
// Close closes the store and releases any resources
func (m *mockStore) Close() error {
return nil
}

655
neo/store/README.md Normal file
View file

@ -0,0 +1,655 @@
# YAO Neo Store
YAO Neo Store is a comprehensive storage abstraction layer for managing conversations, assistants, attachments, and knowledge collections in the YAO Neo platform. It provides a unified interface that supports multiple storage backends including databases (via Xun), Redis, and MongoDB.
## Table of Contents
- [Architecture](#architecture)
- [Storage Backends](#storage-backends)
- [Configuration](#configuration)
- [Initialization](#initialization)
- [API Reference](#api-reference)
- [Data Models](#data-models)
- [Usage Examples](#usage-examples)
- [Testing](#testing)
## Architecture
The store package provides a unified `Store` interface that abstracts different storage implementations:
```
┌─────────────────┐
│ Store API │ ← Unified Interface
├─────────────────┤
│ Xun (Database) │ ← Primary Implementation
│ Redis │ ← Cache/Memory Store
│ MongoDB │ ← Document Store
└─────────────────┘
```
### Core Entities
1. **Conversations & Chat History** - Manage chat sessions and message history
2. **Assistants** - AI assistant configurations and metadata
3. **Attachments** - File attachments with metadata and access control
4. **Knowledge Collections** - Knowledge bases for AI assistants
## Storage Backends
### 1. Xun (Database) - Primary Backend
The main implementation using SQL databases with automatic schema management:
- **Supported Databases**: MySQL, PostgreSQL, SQLite, etc.
- **Features**: ACID transactions, complex queries, automatic migrations
- **Use Case**: Production environments requiring data consistency
### 2. Redis - Cache Backend
Redis implementation for high-performance caching:
- **Features**: In-memory storage, pub/sub capabilities
- **Use Case**: Session management, temporary data, real-time features
### 3. MongoDB - Document Backend
MongoDB implementation for document-based storage:
- **Features**: Schema flexibility, horizontal scaling
- **Use Case**: Large-scale deployments, unstructured data
## Configuration
### Setting Structure
```go
type Setting struct {
Connector string `json:"connector,omitempty"` // Storage connector name
UserField string `json:"user_field,omitempty"` // User ID field name (default: "user_id")
Prefix string `json:"prefix,omitempty"` // Database table name prefix
MaxSize int `json:"max_size,omitempty" yaml:"max_size,omitempty"` // Maximum history size limit
TTL int `json:"ttl,omitempty" yaml:"ttl,omitempty"` // Time To Live in seconds
}
```
### Configuration Examples
#### Database Configuration
```yaml
# app.yao
neo:
store:
connector: "mysql" # or "postgresql", "sqlite", "default"
prefix: "neo_" # Table prefix
max_size: 100 # Maximum chat history size
ttl: 7200 # 2 hours TTL for conversations
user_field: "user_id" # User identification field
```
#### Redis Configuration
```yaml
neo:
store:
connector: "redis"
prefix: "neo:"
ttl: 3600
```
#### MongoDB Configuration
```yaml
neo:
store:
connector: "mongodb"
prefix: "neo_"
ttl: 7200
```
## Initialization
### Automatic Initialization (Recommended)
The store is automatically initialized when the Neo system starts:
```go
// From yao/neo/load.go
func initStore() error {
var err error
if Neo.StoreSetting.Connector == "default" || Neo.StoreSetting.Connector == "" {
Neo.Store, err = store.NewXun(Neo.StoreSetting)
return err
}
// Other connector types
conn, err := connector.Select(Neo.StoreSetting.Connector)
if err != nil {
return err
}
if conn.Is(connector.DATABASE) {
Neo.Store, err = store.NewXun(Neo.StoreSetting)
return err
} else if conn.Is(connector.REDIS) {
Neo.Store = store.NewRedis()
return nil
} else if conn.Is(connector.MONGO) {
Neo.Store = store.NewMongo()
return nil
}
return fmt.Errorf("%s store connector %s not support", Neo.ID, Neo.StoreSetting.Connector)
}
```
### Manual Initialization
```go
import "github.com/yaoapp/yao/neo/store"
// Database backend
setting := store.Setting{
Connector: "mysql",
Prefix: "neo_",
MaxSize: 100,
TTL: 3600,
}
store, err := store.NewXun(setting)
// Redis backend
redisStore := store.NewRedis()
// MongoDB backend
mongoStore := store.NewMongo()
```
## API Reference
### Store Interface
```go
type Store interface {
// Chat Management
GetChats(sid string, filter ChatFilter, locale ...string) (*ChatGroupResponse, error)
GetChat(sid string, cid string, locale ...string) (*ChatInfo, error)
GetChatWithFilter(sid string, cid string, filter ChatFilter, locale ...string) (*ChatInfo, error)
UpdateChatTitle(sid string, cid string, title string) error
DeleteChat(sid string, cid string) error
DeleteAllChats(sid string) error
// Message History
GetHistory(sid string, cid string, locale ...string) ([]map[string]interface{}, error)
GetHistoryWithFilter(sid string, cid string, filter ChatFilter, locale ...string) ([]map[string]interface{}, error)
SaveHistory(sid string, messages []map[string]interface{}, cid string, context map[string]interface{}) error
// Assistant Management
SaveAssistant(assistant map[string]interface{}) (interface{}, error)
GetAssistants(filter AssistantFilter, locale ...string) (*AssistantResponse, error)
GetAssistant(assistantID string, locale ...string) (map[string]interface{}, error)
DeleteAssistant(assistantID string) error
DeleteAssistants(filter AssistantFilter) (int64, error)
GetAssistantTags(locale ...string) ([]Tag, error)
// Attachment Management
SaveAttachment(attachment map[string]interface{}) (interface{}, error)
GetAttachments(filter AttachmentFilter, locale ...string) (*AttachmentResponse, error)
GetAttachment(fileID string, locale ...string) (map[string]interface{}, error)
DeleteAttachment(fileID string) error
DeleteAttachments(filter AttachmentFilter) (int64, error)
// Knowledge Management
SaveKnowledge(knowledge map[string]interface{}) (interface{}, error)
GetKnowledges(filter KnowledgeFilter, locale ...string) (*KnowledgeResponse, error)
GetKnowledge(collectionID string, locale ...string) (map[string]interface{}, error)
DeleteKnowledge(collectionID string) error
DeleteKnowledges(filter KnowledgeFilter) (int64, error)
// Resource Management
Close() error
}
```
## Data Models
### Database Schema
#### 1. History Table (Conversations)
```sql
CREATE TABLE neo_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sid VARCHAR(255) INDEX, -- Session ID
cid VARCHAR(200) INDEX, -- Chat ID
uid VARCHAR(255) INDEX, -- User ID
role VARCHAR(200) INDEX, -- Message role (user/assistant/system)
name VARCHAR(200), -- Message sender name
content TEXT, -- Message content
context JSON, -- Message context
assistant_id VARCHAR(200) INDEX, -- Associated assistant ID
assistant_name VARCHAR(200), -- Assistant name
assistant_avatar VARCHAR(200), -- Assistant avatar URL
mentions JSON, -- Mentions in the message
silent BOOLEAN DEFAULT FALSE INDEX, -- Silent message flag
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP INDEX,
updated_at TIMESTAMP INDEX,
expired_at TIMESTAMP INDEX -- TTL expiration
);
```
#### 2. Chat Table
```sql
CREATE TABLE neo_chat (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
chat_id VARCHAR(200) UNIQUE INDEX, -- Unique chat identifier
title VARCHAR(200), -- Chat title
assistant_id VARCHAR(200) INDEX, -- Associated assistant
sid VARCHAR(255) INDEX, -- Session ID
silent BOOLEAN DEFAULT FALSE INDEX, -- Silent chat flag
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP INDEX,
updated_at TIMESTAMP INDEX
);
```
#### 3. Assistant Table
```sql
CREATE TABLE neo_assistant (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
assistant_id VARCHAR(200) UNIQUE INDEX, -- Unique assistant identifier
type VARCHAR(200) DEFAULT 'assistant' INDEX, -- Assistant type
name VARCHAR(200), -- Assistant name
avatar VARCHAR(200), -- Avatar URL
connector VARCHAR(200) NOT NULL, -- LLM connector
description VARCHAR(600) INDEX, -- Description (searchable)
path VARCHAR(200), -- Storage path
sort INTEGER DEFAULT 9999 INDEX, -- Sort order
built_in BOOLEAN DEFAULT FALSE INDEX, -- Built-in assistant flag
placeholder JSON, -- UI placeholder text
options JSON, -- Assistant options
prompts JSON, -- System prompts
workflow JSON, -- Workflow configuration
knowledge JSON, -- Knowledge base references
tools JSON, -- Available tools
tags JSON, -- Assistant tags
readonly BOOLEAN DEFAULT FALSE INDEX, -- Read-only flag
permissions JSON, -- Access permissions
locales JSON, -- Internationalization data
automated BOOLEAN DEFAULT TRUE INDEX, -- Automation enabled
mentionable BOOLEAN DEFAULT TRUE INDEX, -- Can be mentioned in chats
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP INDEX,
updated_at TIMESTAMP INDEX
);
```
#### 4. Attachment Table
```sql
CREATE TABLE neo_attachment (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_id VARCHAR(255) UNIQUE INDEX, -- Unique file identifier
uid VARCHAR(255) INDEX, -- Owner user ID
guest BOOLEAN DEFAULT FALSE INDEX, -- Guest upload flag
manager VARCHAR(200) INDEX, -- Storage manager
content_type VARCHAR(200) INDEX, -- MIME type
name VARCHAR(500) INDEX, -- File name (searchable)
public BOOLEAN DEFAULT FALSE INDEX, -- Public access flag
scope JSON, -- Access scope
gzip BOOLEAN DEFAULT FALSE INDEX, -- Compression flag
bytes BIGINT INDEX, -- File size
collection_id VARCHAR(200) INDEX, -- Associated knowledge collection
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP INDEX,
updated_at TIMESTAMP INDEX
);
```
#### 5. Knowledge Table
```sql
CREATE TABLE neo_knowledge (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
collection_id VARCHAR(200) UNIQUE INDEX, -- Unique collection identifier
name VARCHAR(200) INDEX, -- Collection name (searchable)
description VARCHAR(600) INDEX, -- Description (searchable)
uid VARCHAR(255) INDEX, -- Owner user ID
public BOOLEAN DEFAULT FALSE INDEX, -- Public access flag
scope JSON, -- Access scope
readonly BOOLEAN DEFAULT FALSE INDEX, -- Read-only flag
option JSON, -- Collection options
system BOOLEAN DEFAULT FALSE INDEX, -- System collection flag
sort INTEGER DEFAULT 9999 INDEX, -- Sort order
cover VARCHAR(500), -- Cover image URL
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP INDEX,
updated_at TIMESTAMP INDEX
);
```
### Filter Structures
#### ChatFilter
```go
type ChatFilter struct {
Keywords string `json:"keywords,omitempty"` // Search keywords
Page int `json:"page,omitempty"` // Page number (starts from 1)
PageSize int `json:"pagesize,omitempty"` // Items per page
Order string `json:"order,omitempty"` // Sort order (desc/asc)
Silent *bool `json:"silent,omitempty"` // Include silent messages
}
```
#### AssistantFilter
```go
type AssistantFilter struct {
Tags []string `json:"tags,omitempty"` // Filter by tags
Type string `json:"type,omitempty"` // Filter by type
Keywords string `json:"keywords,omitempty"` // Search keywords
Connector string `json:"connector,omitempty"` // Filter by connector
AssistantID string `json:"assistant_id,omitempty"` // Specific assistant ID
AssistantIDs []string `json:"assistant_ids,omitempty"` // Multiple assistant IDs
Mentionable *bool `json:"mentionable,omitempty"` // Mentionable status
Automated *bool `json:"automated,omitempty"` // Automation status
BuiltIn *bool `json:"built_in,omitempty"` // Built-in status
Page int `json:"page,omitempty"` // Page number
PageSize int `json:"pagesize,omitempty"` // Items per page
Select []string `json:"select,omitempty"` // Fields to return
}
```
## Usage Examples
### 1. Chat Management
```go
// Save chat history
messages := []map[string]interface{}{
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "I'm doing well, thank you!"},
}
context := map[string]interface{}{
"assistant_id": "gpt-4",
"silent": false,
}
err := store.SaveHistory("user123", messages, "chat456", context)
// Get chat history
history, err := store.GetHistory("user123", "chat456")
// Get chat list with pagination
filter := ChatFilter{
Page: 1,
PageSize: 20,
Order: "desc",
}
chats, err := store.GetChats("user123", filter)
// Update chat title
err = store.UpdateChatTitle("user123", "chat456", "New Chat Title")
```
### 2. Assistant Management
```go
// Create an assistant
assistant := map[string]interface{}{
"name": "Code Helper",
"type": "assistant",
"connector": "gpt-4",
"description": "A helpful coding assistant",
"tags": []string{"coding", "development"},
"sort": 100,
"options": map[string]interface{}{
"temperature": 0.7,
"max_tokens": 2000,
},
"prompts": []string{
"You are a helpful coding assistant.",
},
"mentionable": true,
"automated": true,
}
assistantID, err := store.SaveAssistant(assistant)
// Get assistants with filtering
filter := AssistantFilter{
Tags: []string{"coding"},
Keywords: "helper",
Page: 1,
PageSize: 10,
}
assistants, err := store.GetAssistants(filter)
// Get specific assistant
assistant, err := store.GetAssistant("assistant123")
```
### 3. Attachment Management
```go
// Save attachment metadata
attachment := map[string]interface{}{
"file_id": "file123",
"uid": "user123",
"manager": "local",
"content_type": "image/jpeg",
"name": "profile.jpg",
"public": false,
"bytes": 102400,
"collection_id": "knowledge456",
"scope": []string{"user", "admin"},
}
fileID, err := store.SaveAttachment(attachment)
// Get attachments with filtering
filter := AttachmentFilter{
UID: "user123",
ContentType: "image/jpeg",
Page: 1,
PageSize: 20,
}
attachments, err := store.GetAttachments(filter)
```
### 4. Knowledge Collection Management
```go
// Create knowledge collection
knowledge := map[string]interface{}{
"collection_id": "kb123",
"name": "Programming Guide",
"description": "Comprehensive programming tutorials and examples",
"uid": "user123",
"public": true,
"readonly": false,
"sort": 100,
"option": map[string]interface{}{
"embedding": "openai",
"chunk_size": 1000,
},
"scope": []string{"developers", "students"},
}
collectionID, err := store.SaveKnowledge(knowledge)
// Get knowledge collections with filtering
filter := KnowledgeFilter{
UID: "user123",
Keywords: "programming",
Public: &[]bool{true}[0],
Page: 1,
PageSize: 10,
}
collections, err := store.GetKnowledges(filter)
```
### 5. Internationalization Support
```go
// Get assistants with locale
assistants, err := store.GetAssistants(filter, "zh-CN")
// Get chat with locale
chat, err := store.GetChat("user123", "chat456", "en-US")
```
### 6. Advanced Filtering and Sorting
```go
// Complex assistant filtering
filter := AssistantFilter{
Tags: []string{"ai", "assistant"},
Keywords: "helpful",
Connector: "gpt-4",
Mentionable: &[]bool{true}[0],
BuiltIn: &[]bool{false}[0],
Select: []string{"assistant_id", "name", "description", "tags"},
Page: 1,
PageSize: 50,
}
assistants, err := store.GetAssistants(filter)
// Results are automatically sorted by:
// 1. sort field (ASC) - lower numbers appear first
// 2. created_at/updated_at (DESC) - newer items appear first
```
## Testing
### Running Tests
```bash
# Run all tests
go test -v
# Run specific test
go test -run TestXunKnowledgeCRUD -v
# Run with coverage
go test -cover
```
### Test Structure
The test suite includes comprehensive coverage for:
- **CRUD Operations**: Create, Read, Update, Delete for all entities
- **Filtering**: Various filter combinations and edge cases
- **Sorting**: Verify sort order and pagination
- **Error Handling**: Invalid inputs and edge cases
- **Internationalization**: Locale-specific operations
- **Concurrency**: Multiple concurrent operations
### Test Database Setup
Tests use isolated table prefixes to avoid conflicts:
```go
store, err := NewXun(Setting{
Connector: "default",
Prefix: "__unit_test_conversation_",
TTL: 3600,
})
```
## Performance Considerations
### Database Optimization
1. **Indexes**: All frequently queried fields have indexes
2. **TTL**: Automatic cleanup of expired data
3. **Pagination**: All list operations support pagination
4. **Connection Pooling**: Efficient database connection management
### Caching Strategy
1. **Redis Backend**: For high-frequency read operations
2. **Memory Caching**: In-application caching for static data
3. **Query Optimization**: Efficient filtering and sorting
### Scaling
1. **Horizontal Scaling**: MongoDB support for distributed deployments
2. **Read Replicas**: Database read/write splitting
3. **Sharding**: Data partitioning strategies
## Migration and Upgrades
### Schema Evolution
The Xun backend automatically handles schema migrations:
- New tables are created automatically
- New fields are added with default values
- Indexes are created during initialization
### Data Migration
When switching between backends:
1. Export data from source backend
2. Transform data format if necessary
3. Import to target backend
4. Verify data integrity
## Security
### Access Control
1. **User Isolation**: All operations are user-scoped
2. **Permission System**: Fine-grained access control
3. **Public/Private Flags**: Content visibility management
### Data Protection
1. **Input Validation**: All inputs are validated and sanitized
2. **SQL Injection Prevention**: Parameterized queries
3. **XSS Protection**: Content encoding and sanitization
## Troubleshooting
### Common Issues
1. **Connection Errors**: Check connector configuration
2. **Schema Errors**: Verify database permissions
3. **Performance Issues**: Check indexes and query patterns
4. **Memory Issues**: Monitor TTL and cleanup processes
### Debugging
Enable debug logging:
```go
import "github.com/yaoapp/kun/log"
log.SetLevel(log.DebugLevel)
```
### Monitoring
Key metrics to monitor:
- Database connection pool usage
- Query performance and slow queries
- Memory usage and garbage collection
- TTL cleanup effectiveness
## Contributing
### Development Setup
1. Clone the repository
2. Install dependencies: `go mod download`
3. Run tests: `go test -v`
4. Follow Go coding standards
### Adding New Features
1. Update the Store interface
2. Implement in all backends (Xun, Redis, MongoDB)
3. Add comprehensive tests
4. Update documentation
## License
This project is part of the YAO framework and follows the same license terms.

View file

@ -82,3 +82,58 @@ func (m *Mongo) DeleteAssistants(filter AssistantFilter) (int64, error) {
func (m *Mongo) GetAssistantTags(locale ...string) ([]Tag, error) {
return []Tag{}, nil
}
// SaveAttachment saves attachment information
func (m *Mongo) SaveAttachment(attachment map[string]interface{}) (interface{}, error) {
return attachment["file_id"], nil
}
// DeleteAttachment deletes an attachment
func (m *Mongo) DeleteAttachment(fileID string) error {
return nil
}
// GetAttachments retrieves a list of attachments
func (m *Mongo) GetAttachments(filter AttachmentFilter, locale ...string) (*AttachmentResponse, error) {
return &AttachmentResponse{}, nil
}
// GetAttachment retrieves a single attachment by file ID
func (m *Mongo) GetAttachment(fileID string, locale ...string) (map[string]interface{}, error) {
return nil, nil
}
// DeleteAttachments deletes attachments based on filter conditions
func (m *Mongo) DeleteAttachments(filter AttachmentFilter) (int64, error) {
return 0, nil
}
// SaveKnowledge saves knowledge collection information
func (m *Mongo) SaveKnowledge(knowledge map[string]interface{}) (interface{}, error) {
return knowledge["collection_id"], nil
}
// DeleteKnowledge deletes a knowledge collection
func (m *Mongo) DeleteKnowledge(collectionID string) error {
return nil
}
// GetKnowledges retrieves a list of knowledge collections
func (m *Mongo) GetKnowledges(filter KnowledgeFilter, locale ...string) (*KnowledgeResponse, error) {
return &KnowledgeResponse{}, nil
}
// GetKnowledge retrieves a single knowledge collection by ID
func (m *Mongo) GetKnowledge(collectionID string, locale ...string) (map[string]interface{}, error) {
return nil, nil
}
// DeleteKnowledges deletes knowledge collections based on filter conditions
func (m *Mongo) DeleteKnowledges(filter KnowledgeFilter) (int64, error) {
return 0, nil
}
// Close closes the store and releases any resources
func (m *Mongo) Close() error {
return nil
}

View file

@ -82,3 +82,58 @@ func (r *Redis) DeleteAssistants(filter AssistantFilter) (int64, error) {
func (r *Redis) GetAssistantTags(locale ...string) ([]Tag, error) {
return []Tag{}, nil
}
// SaveAttachment saves attachment information
func (r *Redis) SaveAttachment(attachment map[string]interface{}) (interface{}, error) {
return attachment["file_id"], nil
}
// DeleteAttachment deletes an attachment
func (r *Redis) DeleteAttachment(fileID string) error {
return nil
}
// GetAttachments retrieves a list of attachments
func (r *Redis) GetAttachments(filter AttachmentFilter, locale ...string) (*AttachmentResponse, error) {
return &AttachmentResponse{}, nil
}
// GetAttachment retrieves a single attachment by file ID
func (r *Redis) GetAttachment(fileID string, locale ...string) (map[string]interface{}, error) {
return nil, nil
}
// DeleteAttachments deletes attachments based on filter conditions
func (r *Redis) DeleteAttachments(filter AttachmentFilter) (int64, error) {
return 0, nil
}
// SaveKnowledge saves knowledge collection information
func (r *Redis) SaveKnowledge(knowledge map[string]interface{}) (interface{}, error) {
return knowledge["collection_id"], nil
}
// DeleteKnowledge deletes a knowledge collection
func (r *Redis) DeleteKnowledge(collectionID string) error {
return nil
}
// GetKnowledges retrieves a list of knowledge collections
func (r *Redis) GetKnowledges(filter KnowledgeFilter, locale ...string) (*KnowledgeResponse, error) {
return &KnowledgeResponse{}, nil
}
// GetKnowledge retrieves a single knowledge collection by ID
func (r *Redis) GetKnowledge(collectionID string, locale ...string) (map[string]interface{}, error) {
return nil, nil
}
// DeleteKnowledges deletes knowledge collections based on filter conditions
func (r *Redis) DeleteKnowledges(filter KnowledgeFilter) (int64, error) {
return 0, nil
}
// Close closes the store and releases any resources
func (r *Redis) Close() error {
return nil
}

View file

@ -168,4 +168,113 @@ type Store interface {
// filter: Filter conditions
// Returns: Number of deleted records and potential error
DeleteAssistants(filter AssistantFilter) (int64, error)
// SaveAttachment saves attachment information
// attachment: Attachment information
// Returns: Attachment ID and potential error
SaveAttachment(attachment map[string]interface{}) (interface{}, error)
// DeleteAttachment deletes an attachment
// fileID: Attachment file ID
// Returns: Potential error
DeleteAttachment(fileID string) error
// GetAttachments retrieves a list of attachments
// filter: Filter conditions
// Returns: Paginated attachment list and potential error
GetAttachments(filter AttachmentFilter, locale ...string) (*AttachmentResponse, error)
// GetAttachment retrieves a single attachment by file ID
// fileID: Attachment file ID
// Returns: Attachment information and potential error
GetAttachment(fileID string, locale ...string) (map[string]interface{}, error)
// DeleteAttachments deletes attachments based on filter conditions
// filter: Filter conditions
// Returns: Number of deleted records and potential error
DeleteAttachments(filter AttachmentFilter) (int64, error)
// SaveKnowledge saves knowledge collection information
// knowledge: Knowledge collection information
// Returns: Collection ID and potential error
SaveKnowledge(knowledge map[string]interface{}) (interface{}, error)
// DeleteKnowledge deletes a knowledge collection
// collectionID: Knowledge collection ID
// Returns: Potential error
DeleteKnowledge(collectionID string) error
// GetKnowledges retrieves a list of knowledge collections
// filter: Filter conditions
// Returns: Paginated knowledge collection list and potential error
GetKnowledges(filter KnowledgeFilter, locale ...string) (*KnowledgeResponse, error)
// GetKnowledge retrieves a single knowledge collection by ID
// collectionID: Knowledge collection ID
// Returns: Knowledge collection information and potential error
GetKnowledge(collectionID string, locale ...string) (map[string]interface{}, error)
// DeleteKnowledges deletes knowledge collections based on filter conditions
// filter: Filter conditions
// Returns: Number of deleted records and potential error
DeleteKnowledges(filter KnowledgeFilter) (int64, error)
// Close closes the store and releases any resources
// Returns: Potential error
Close() error
}
// AttachmentFilter represents the attachment filter structure
// Used for filtering and pagination when retrieving attachment lists
type AttachmentFilter struct {
UID string `json:"uid,omitempty"` // Filter by user ID
Guest *bool `json:"guest,omitempty"` // Filter by guest status
Manager string `json:"manager,omitempty"` // Filter by upload manager
ContentType string `json:"content_type,omitempty"` // Filter by content type
Name string `json:"name,omitempty"` // Filter by filename
Public *bool `json:"public,omitempty"` // Filter by public status
Gzip *bool `json:"gzip,omitempty"` // Filter by gzip compression
CollectionID string `json:"collection_id,omitempty"` // Filter by knowledge collection ID
Keywords string `json:"keywords,omitempty"` // Search in filename
Page int `json:"page,omitempty"` // Page number, starting from 1
PageSize int `json:"pagesize,omitempty"` // Items per page
Select []string `json:"select,omitempty"` // Fields to return, returns all fields if empty
}
// AttachmentResponse represents the attachment response structure
// Used for returning paginated attachment lists
type AttachmentResponse struct {
Data []map[string]interface{} `json:"data"` // The paginated data
Page int `json:"page"` // Current page number
PageSize int `json:"pagesize"` // Number of items per page
PageCnt int `json:"pagecnt"` // Total number of pages
Next int `json:"next"` // Next page number
Prev int `json:"prev"` // Previous page number
Total int64 `json:"total"` // Total number of items
}
// KnowledgeFilter represents the knowledge filter structure
// Used for filtering and pagination when retrieving knowledge lists
type KnowledgeFilter struct {
UID string `json:"uid,omitempty"` // Filter by user ID
Name string `json:"name,omitempty"` // Filter by collection name
Keywords string `json:"keywords,omitempty"` // Search in name and description
Public *bool `json:"public,omitempty"` // Filter by public status
Readonly *bool `json:"readonly,omitempty"` // Filter by readonly status
System *bool `json:"system,omitempty"` // Filter by system status
Page int `json:"page,omitempty"` // Page number, starting from 1
PageSize int `json:"pagesize,omitempty"` // Items per page
Select []string `json:"select,omitempty"` // Fields to return, returns all fields if empty
}
// KnowledgeResponse represents the knowledge response structure
// Used for returning paginated knowledge lists
type KnowledgeResponse struct {
Data []map[string]interface{} `json:"data"` // The paginated data
Page int `json:"page"` // Current page number
PageSize int `json:"pagesize"` // Number of items per page
PageCnt int `json:"pagecnt"` // Total number of pages
Next int `json:"next"` // Next page number
Prev int `json:"prev"` // Previous page number
Total int64 `json:"total"` // Total number of items
}

View file

@ -25,11 +25,15 @@ import (
// - Organizing chats with pagination and date-based grouping
// - Handling chat metadata like titles and creation dates
// - Managing AI assistants with their configurations and metadata
// - Managing file attachments with metadata and access control
// - Managing knowledge collections for AI assistants
// - Supporting data expiration through TTL settings
type Xun struct {
query query.Query
schema schema.Schema
setting Setting
query query.Query
schema schema.Schema
setting Setting
cleanTicker *time.Ticker
cleanStop chan bool
}
// Public interface methods:
@ -46,6 +50,14 @@ type Xun struct {
// DeleteAssistant deletes an assistant by assistant_id
// GetAssistants retrieves a paginated list of assistants with filtering
// GetAssistant retrieves a single assistant by assistant_id
// SaveAttachment creates or updates an attachment
// DeleteAttachment deletes an attachment by file_id
// GetAttachments retrieves a paginated list of attachments with filtering
// GetAttachment retrieves a single attachment by file_id
// SaveKnowledge creates or updates a knowledge collection
// DeleteKnowledge deletes a knowledge collection by collection_id
// GetKnowledges retrieves a paginated list of knowledge collections with filtering
// GetKnowledge retrieves a single knowledge collection by collection_id
// NewXun create a new xun store
func NewXun(setting Setting) (Store, error) {
@ -104,6 +116,50 @@ func (conv *Xun) clean() {
}
}
// startAutoClean starts the automatic cleanup routine
func (conv *Xun) startAutoClean() {
if conv.cleanTicker != nil {
conv.stopAutoClean() // Stop existing ticker if any
}
conv.cleanTicker = time.NewTicker(1 * time.Hour) // Clean every hour
conv.cleanStop = make(chan bool)
go func() {
for {
select {
case <-conv.cleanTicker.C:
conv.clean()
case <-conv.cleanStop:
return
}
}
}()
log.Trace("Started automatic cleanup for: %s", conv.setting.Prefix)
}
// stopAutoClean stops the automatic cleanup routine
func (conv *Xun) stopAutoClean() {
if conv.cleanTicker != nil {
conv.cleanTicker.Stop()
conv.cleanTicker = nil
}
if conv.cleanStop != nil {
close(conv.cleanStop)
conv.cleanStop = nil
}
log.Trace("Stopped automatic cleanup for: %s", conv.setting.Prefix)
}
// Close stops the automatic cleanup and closes resources
func (conv *Xun) Close() error {
conv.stopAutoClean()
return nil
}
// Rename Init to initialize to avoid conflicts
func (conv *Xun) initialize() error {
// Initialize history table
@ -121,6 +177,21 @@ func (conv *Xun) initialize() error {
return err
}
// Initialize attachment table
if err := conv.initAttachmentTable(); err != nil {
return err
}
// Initialize knowledge table
if err := conv.initKnowledgeTable(); err != nil {
return err
}
// Start automatic cleanup if TTL is enabled
if conv.setting.TTL > 0 {
conv.startAutoClean()
}
return nil
}
@ -232,7 +303,7 @@ func (conv *Xun) initAssistantTable() error {
table.String("name", 200).Null() // assistant name
table.String("avatar", 200).Null() // assistant avatar
table.String("connector", 200).NotNull() // assistant connector
table.Text("description").Null() // assistant description
table.String("description", 600).Null().Index() // assistant description
table.String("path", 200).Null() // assistant storage path
table.Integer("sort").SetDefault(9999).Index() // assistant sort order
table.Boolean("built_in").SetDefault(false).Index() // whether this is a built-in assistant
@ -274,6 +345,102 @@ func (conv *Xun) initAssistantTable() error {
return nil
}
func (conv *Xun) initAttachmentTable() error {
attachmentTable := conv.getAttachmentTable()
has, err := conv.schema.HasTable(attachmentTable)
if err != nil {
return err
}
// Create the attachment table
if !has {
err = conv.schema.CreateTable(attachmentTable, func(table schema.Blueprint) {
table.ID("id")
table.String("file_id", 255).Unique().Index()
table.String("uid", 255).Index()
table.Boolean("guest").SetDefault(false).Index()
table.String("manager", 200).Index()
table.String("content_type", 200).Index()
table.String("name", 500).Index()
table.Boolean("public").SetDefault(false).Index()
table.JSON("scope").Null()
table.Boolean("gzip").SetDefault(false).Index()
table.BigInteger("bytes").Index()
table.String("collection_id", 200).Null().Index()
table.TimestampTz("created_at").SetDefaultRaw("CURRENT_TIMESTAMP").Index()
table.TimestampTz("updated_at").Null().Index()
})
if err != nil {
return err
}
log.Trace("Create the attachment table: %s", attachmentTable)
}
// Validate the table
tab, err := conv.schema.GetTable(attachmentTable)
if err != nil {
return err
}
fields := []string{"id", "file_id", "uid", "guest", "manager", "content_type", "name", "public", "scope", "gzip", "bytes", "collection_id", "created_at", "updated_at"}
for _, field := range fields {
if !tab.HasColumn(field) {
return fmt.Errorf("%s is required", field)
}
}
return nil
}
func (conv *Xun) initKnowledgeTable() error {
knowledgeTable := conv.getKnowledgeTable()
has, err := conv.schema.HasTable(knowledgeTable)
if err != nil {
return err
}
// Create the knowledge table
if !has {
err = conv.schema.CreateTable(knowledgeTable, func(table schema.Blueprint) {
table.ID("id")
table.String("collection_id", 200).Unique().Index()
table.String("name", 200).Index()
table.String("description", 600).Null().Index() // knowledge description
table.String("uid", 255).Index()
table.Boolean("public").SetDefault(false).Index()
table.JSON("scope").Null()
table.Boolean("readonly").SetDefault(false).Index()
table.JSON("option").Null()
table.Boolean("system").SetDefault(false).Index()
table.Integer("sort").SetDefault(9999).Index() // knowledge sort order
table.String("cover", 500).Null()
table.TimestampTz("created_at").SetDefaultRaw("CURRENT_TIMESTAMP").Index()
table.TimestampTz("updated_at").Null().Index()
})
if err != nil {
return err
}
log.Trace("Create the knowledge table: %s", knowledgeTable)
}
// Validate the table
tab, err := conv.schema.GetTable(knowledgeTable)
if err != nil {
return err
}
fields := []string{"id", "collection_id", "name", "description", "uid", "public", "scope", "readonly", "option", "system", "sort", "cover", "created_at", "updated_at"}
for _, field := range fields {
if !tab.HasColumn(field) {
return fmt.Errorf("%s is required", field)
}
}
return nil
}
func (conv *Xun) getUserID(sid string) (string, error) {
field := "user_id"
if conv.setting.UserField != "" {
@ -304,6 +471,26 @@ func (conv *Xun) getAssistantTable() string {
return conv.setting.Prefix + "assistant"
}
func (conv *Xun) getAttachmentTable() string {
return conv.setting.Prefix + "attachment"
}
func (conv *Xun) getKnowledgeTable() string {
return conv.setting.Prefix + "knowledge"
}
func (conv *Xun) newQueryAttachment() query.Query {
qb := conv.query.New()
qb.Table(conv.getAttachmentTable())
return qb
}
func (conv *Xun) newQueryKnowledge() query.Query {
qb := conv.query.New()
qb.Table(conv.getKnowledgeTable())
return qb
}
// UpdateChatTitle update the chat title
func (conv *Xun) UpdateChatTitle(sid string, cid string, title string) error {
userID, err := conv.getUserID(sid)
@ -701,7 +888,6 @@ func (conv *Xun) SaveHistory(sid string, messages []map[string]interface{}, cid
}
// Save message history
defer conv.clean()
var expiredAt interface{} = nil
values := []map[string]interface{}{}
if conv.setting.TTL > 0 {
@ -1459,3 +1645,596 @@ func (conv *Xun) GenerateAssistantID() (string, error) {
return "", fmt.Errorf("failed to generate unique ID after %d attempts", maxAttempts)
}
// SaveAttachment saves attachment information
func (conv *Xun) SaveAttachment(attachment map[string]interface{}) (interface{}, error) {
// Validate required fields
requiredFields := []string{"file_id", "uid", "manager", "content_type", "name"}
for _, field := range requiredFields {
if _, ok := attachment[field]; !ok {
return nil, fmt.Errorf("field %s is required", field)
}
if attachment[field] == nil || attachment[field] == "" {
return nil, fmt.Errorf("field %s cannot be empty", field)
}
}
// Create a copy of the attachment map to avoid modifying the original
attachmentCopy := make(map[string]interface{})
for k, v := range attachment {
attachmentCopy[k] = v
}
// Process JSON fields
jsonFields := []string{"scope"}
for _, field := range jsonFields {
if val, ok := attachmentCopy[field]; ok && val != nil {
// If it's a string, try to parse it first
if strVal, ok := val.(string); ok && strVal != "" {
var parsed interface{}
if err := jsoniter.UnmarshalFromString(strVal, &parsed); err == nil {
attachmentCopy[field] = parsed
}
}
}
}
// Check if attachment exists
exists, err := conv.query.New().
Table(conv.getAttachmentTable()).
Where("file_id", attachmentCopy["file_id"]).
Exists()
if err != nil {
return nil, err
}
// Convert JSON fields to strings for storage
for _, field := range jsonFields {
if val, ok := attachmentCopy[field]; ok && val != nil {
jsonStr, err := jsoniter.MarshalToString(val)
if err != nil {
return nil, fmt.Errorf("failed to marshal %s to JSON: %v", field, err)
}
attachmentCopy[field] = jsonStr
}
}
// Update or insert
if exists {
attachmentCopy["updated_at"] = time.Now()
_, err := conv.query.New().
Table(conv.getAttachmentTable()).
Where("file_id", attachmentCopy["file_id"]).
Update(attachmentCopy)
if err != nil {
return nil, err
}
return attachmentCopy["file_id"], nil
}
attachmentCopy["created_at"] = time.Now()
err = conv.query.New().
Table(conv.getAttachmentTable()).
Insert(attachmentCopy)
if err != nil {
return nil, err
}
return attachmentCopy["file_id"], nil
}
// DeleteAttachment deletes an attachment by file_id
func (conv *Xun) DeleteAttachment(fileID string) error {
// Check if attachment exists
exists, err := conv.query.New().
Table(conv.getAttachmentTable()).
Where("file_id", fileID).
Exists()
if err != nil {
return err
}
if !exists {
return fmt.Errorf("attachment %s not found", fileID)
}
_, err = conv.query.New().
Table(conv.getAttachmentTable()).
Where("file_id", fileID).
Delete()
return err
}
// GetAttachments retrieves attachments with pagination and filtering
func (conv *Xun) GetAttachments(filter AttachmentFilter, locale ...string) (*AttachmentResponse, error) {
qb := conv.query.New().
Table(conv.getAttachmentTable())
// Apply UID filter if provided
if filter.UID != "" {
qb.Where("uid", filter.UID)
}
// Apply guest filter if provided
if filter.Guest != nil {
qb.Where("guest", *filter.Guest)
}
// Apply manager filter if provided
if filter.Manager != "" {
qb.Where("manager", filter.Manager)
}
// Apply content_type filter if provided
if filter.ContentType != "" {
qb.Where("content_type", filter.ContentType)
}
// Apply name filter if provided
if filter.Name != "" {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Name))
}
// Apply public filter if provided
if filter.Public != nil {
qb.Where("public", *filter.Public)
}
// Apply gzip filter if provided
if filter.Gzip != nil {
qb.Where("gzip", *filter.Gzip)
}
// Apply collection_id filter if provided
if filter.CollectionID != "" {
qb.Where("collection_id", filter.CollectionID)
}
// Apply keyword filter if provided
if filter.Keywords != "" {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
}
// Set defaults for pagination
if filter.PageSize <= 0 {
filter.PageSize = 20
}
if filter.Page <= 0 {
filter.Page = 1
}
// Get total count
total, err := qb.Clone().Count()
if err != nil {
return nil, err
}
// Calculate pagination
offset := (filter.Page - 1) * filter.PageSize
totalPages := int(math.Ceil(float64(total) / float64(filter.PageSize)))
nextPage := filter.Page + 1
if nextPage > totalPages {
nextPage = 0
}
prevPage := filter.Page - 1
if prevPage < 1 {
prevPage = 0
}
// Apply select fields if provided
if filter.Select != nil && len(filter.Select) > 0 {
selectFields := make([]interface{}, len(filter.Select))
for i, field := range filter.Select {
selectFields[i] = field
}
qb.Select(selectFields...)
}
// Get paginated results
rows, err := qb.OrderBy("created_at", "desc").
Offset(offset).
Limit(filter.PageSize).
Get()
if err != nil {
return nil, err
}
// Convert rows to map slice and parse JSON fields
data := make([]map[string]interface{}, len(rows))
jsonFields := []string{"scope"}
for i, row := range rows {
data[i] = row
// Only parse JSON fields if they are selected or no select filter is provided
if filter.Select == nil || len(filter.Select) == 0 {
conv.parseJSONFields(data[i], jsonFields)
} else {
// Parse only selected JSON fields
selectedJSONFields := []string{}
for _, field := range jsonFields {
for _, selected := range filter.Select {
if selected == field {
selectedJSONFields = append(selectedJSONFields, field)
break
}
}
}
if len(selectedJSONFields) > 0 {
conv.parseJSONFields(data[i], selectedJSONFields)
}
}
}
return &AttachmentResponse{
Data: data,
Page: filter.Page,
PageSize: filter.PageSize,
PageCnt: totalPages,
Next: nextPage,
Prev: prevPage,
Total: total,
}, nil
}
// GetAttachment retrieves a single attachment by file_id
func (conv *Xun) GetAttachment(fileID string, locale ...string) (map[string]interface{}, error) {
row, err := conv.query.New().
Table(conv.getAttachmentTable()).
Where("file_id", fileID).
First()
if err != nil {
return nil, err
}
if row == nil {
return nil, fmt.Errorf("attachment %s not found", fileID)
}
data := row.ToMap()
if data == nil || len(data) == 0 {
return nil, fmt.Errorf("the attachment %s is empty", fileID)
}
// Parse JSON fields
jsonFields := []string{"scope"}
conv.parseJSONFields(data, jsonFields)
return data, nil
}
// DeleteAttachments deletes attachments based on filter conditions
func (conv *Xun) DeleteAttachments(filter AttachmentFilter) (int64, error) {
qb := conv.query.New().
Table(conv.getAttachmentTable())
// Apply UID filter if provided
if filter.UID != "" {
qb.Where("uid", filter.UID)
}
// Apply guest filter if provided
if filter.Guest != nil {
qb.Where("guest", *filter.Guest)
}
// Apply manager filter if provided
if filter.Manager != "" {
qb.Where("manager", filter.Manager)
}
// Apply content_type filter if provided
if filter.ContentType != "" {
qb.Where("content_type", filter.ContentType)
}
// Apply name filter if provided
if filter.Name != "" {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Name))
}
// Apply public filter if provided
if filter.Public != nil {
qb.Where("public", *filter.Public)
}
// Apply gzip filter if provided
if filter.Gzip != nil {
qb.Where("gzip", *filter.Gzip)
}
// Apply collection_id filter if provided
if filter.CollectionID != "" {
qb.Where("collection_id", filter.CollectionID)
}
// Apply keyword filter if provided
if filter.Keywords != "" {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
}
// Execute delete and return number of deleted records
return qb.Delete()
}
// SaveKnowledge saves knowledge collection information
func (conv *Xun) SaveKnowledge(knowledge map[string]interface{}) (interface{}, error) {
// Validate required fields
requiredFields := []string{"collection_id", "name", "uid"}
for _, field := range requiredFields {
if _, ok := knowledge[field]; !ok {
return nil, fmt.Errorf("field %s is required", field)
}
if knowledge[field] == nil || knowledge[field] == "" {
return nil, fmt.Errorf("field %s cannot be empty", field)
}
}
// Create a copy of the knowledge map to avoid modifying the original
knowledgeCopy := make(map[string]interface{})
for k, v := range knowledge {
knowledgeCopy[k] = v
}
// Process JSON fields
jsonFields := []string{"scope", "option"}
for _, field := range jsonFields {
if val, ok := knowledgeCopy[field]; ok && val != nil {
// If it's a string, try to parse it first
if strVal, ok := val.(string); ok && strVal != "" {
var parsed interface{}
if err := jsoniter.UnmarshalFromString(strVal, &parsed); err == nil {
knowledgeCopy[field] = parsed
}
}
}
}
// Check if knowledge exists
exists, err := conv.query.New().
Table(conv.getKnowledgeTable()).
Where("collection_id", knowledgeCopy["collection_id"]).
Exists()
if err != nil {
return nil, err
}
// Convert JSON fields to strings for storage
for _, field := range jsonFields {
if val, ok := knowledgeCopy[field]; ok && val != nil {
jsonStr, err := jsoniter.MarshalToString(val)
if err != nil {
return nil, fmt.Errorf("failed to marshal %s to JSON: %v", field, err)
}
knowledgeCopy[field] = jsonStr
}
}
// Update or insert
if exists {
knowledgeCopy["updated_at"] = time.Now()
_, err := conv.query.New().
Table(conv.getKnowledgeTable()).
Where("collection_id", knowledgeCopy["collection_id"]).
Update(knowledgeCopy)
if err != nil {
return nil, err
}
return knowledgeCopy["collection_id"], nil
}
knowledgeCopy["created_at"] = time.Now()
err = conv.query.New().
Table(conv.getKnowledgeTable()).
Insert(knowledgeCopy)
if err != nil {
return nil, err
}
return knowledgeCopy["collection_id"], nil
}
// DeleteKnowledge deletes a knowledge collection by collection_id
func (conv *Xun) DeleteKnowledge(collectionID string) error {
// Check if knowledge exists
exists, err := conv.query.New().
Table(conv.getKnowledgeTable()).
Where("collection_id", collectionID).
Exists()
if err != nil {
return err
}
if !exists {
return fmt.Errorf("knowledge collection %s not found", collectionID)
}
_, err = conv.query.New().
Table(conv.getKnowledgeTable()).
Where("collection_id", collectionID).
Delete()
return err
}
// GetKnowledges retrieves knowledge collections with pagination and filtering
func (conv *Xun) GetKnowledges(filter KnowledgeFilter, locale ...string) (*KnowledgeResponse, error) {
qb := conv.query.New().
Table(conv.getKnowledgeTable())
// Apply UID filter if provided
if filter.UID != "" {
qb.Where("uid", filter.UID)
}
// Apply name filter if provided
if filter.Name != "" {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Name))
}
// Apply keyword filter if provided
if filter.Keywords != "" {
qb.Where(func(qb query.Query) {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
OrWhere("description", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
})
}
// Apply public filter if provided
if filter.Public != nil {
qb.Where("public", *filter.Public)
}
// Apply readonly filter if provided
if filter.Readonly != nil {
qb.Where("readonly", *filter.Readonly)
}
// Apply system filter if provided
if filter.System != nil {
qb.Where("system", *filter.System)
}
// Set defaults for pagination
if filter.PageSize <= 0 {
filter.PageSize = 20
}
if filter.Page <= 0 {
filter.Page = 1
}
// Get total count
total, err := qb.Clone().Count()
if err != nil {
return nil, err
}
// Calculate pagination
offset := (filter.Page - 1) * filter.PageSize
totalPages := int(math.Ceil(float64(total) / float64(filter.PageSize)))
nextPage := filter.Page + 1
if nextPage > totalPages {
nextPage = 0
}
prevPage := filter.Page - 1
if prevPage < 1 {
prevPage = 0
}
// Apply select fields if provided
if filter.Select != nil && len(filter.Select) > 0 {
selectFields := make([]interface{}, len(filter.Select))
for i, field := range filter.Select {
selectFields[i] = field
}
qb.Select(selectFields...)
}
// Get paginated results
rows, err := qb.OrderBy("sort", "asc").
OrderBy("created_at", "desc").
Offset(offset).
Limit(filter.PageSize).
Get()
if err != nil {
return nil, err
}
// Convert rows to map slice and parse JSON fields
data := make([]map[string]interface{}, len(rows))
jsonFields := []string{"scope", "option"}
for i, row := range rows {
data[i] = row
// Only parse JSON fields if they are selected or no select filter is provided
if filter.Select == nil || len(filter.Select) == 0 {
conv.parseJSONFields(data[i], jsonFields)
} else {
// Parse only selected JSON fields
selectedJSONFields := []string{}
for _, field := range jsonFields {
for _, selected := range filter.Select {
if selected == field {
selectedJSONFields = append(selectedJSONFields, field)
break
}
}
}
if len(selectedJSONFields) > 0 {
conv.parseJSONFields(data[i], selectedJSONFields)
}
}
}
return &KnowledgeResponse{
Data: data,
Page: filter.Page,
PageSize: filter.PageSize,
PageCnt: totalPages,
Next: nextPage,
Prev: prevPage,
Total: total,
}, nil
}
// GetKnowledge retrieves a single knowledge collection by collection_id
func (conv *Xun) GetKnowledge(collectionID string, locale ...string) (map[string]interface{}, error) {
row, err := conv.query.New().
Table(conv.getKnowledgeTable()).
Where("collection_id", collectionID).
First()
if err != nil {
return nil, err
}
if row == nil {
return nil, fmt.Errorf("knowledge collection %s not found", collectionID)
}
data := row.ToMap()
if data == nil || len(data) == 0 {
return nil, fmt.Errorf("the knowledge collection %s is empty", collectionID)
}
// Parse JSON fields
jsonFields := []string{"scope", "option"}
conv.parseJSONFields(data, jsonFields)
return data, nil
}
// DeleteKnowledges deletes knowledge collections based on filter conditions
func (conv *Xun) DeleteKnowledges(filter KnowledgeFilter) (int64, error) {
qb := conv.query.New().
Table(conv.getKnowledgeTable())
// Apply UID filter if provided
if filter.UID != "" {
qb.Where("uid", filter.UID)
}
// Apply name filter if provided
if filter.Name != "" {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Name))
}
// Apply keyword filter if provided
if filter.Keywords != "" {
qb.Where(func(qb query.Query) {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
OrWhere("description", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
})
}
// Apply public filter if provided
if filter.Public != nil {
qb.Where("public", *filter.Public)
}
// Apply readonly filter if provided
if filter.Readonly != nil {
qb.Where("readonly", *filter.Readonly)
}
// Apply system filter if provided
if filter.System != nil {
qb.Where("system", *filter.System)
}
// Execute delete and return number of deleted records
return qb.Delete()
}

View file

@ -128,10 +128,14 @@ func TestNewXunConnector(t *testing.T) {
defer sch.DropTableIfExists("__unit_test_conversation_history")
defer sch.DropTableIfExists("__unit_test_conversation_chat")
defer sch.DropTableIfExists("__unit_test_conversation_assistant")
defer sch.DropTableIfExists("__unit_test_conversation_knowledge")
defer sch.DropTableIfExists("__unit_test_conversation_attachment")
sch.DropTableIfExists("__unit_test_conversation_history")
sch.DropTableIfExists("__unit_test_conversation_chat")
sch.DropTableIfExists("__unit_test_conversation_assistant")
sch.DropTableIfExists("__unit_test_conversation_knowledge")
sch.DropTableIfExists("__unit_test_conversation_attachment")
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
@ -614,8 +618,8 @@ func TestXunAssistantCRUD(t *testing.T) {
"tags": []string{"tag1", "tag2", "tag3"},
"options": map[string]interface{}{"model": "gpt-4"},
"prompts": []string{"prompt1", "prompt2"},
"flows": []string{"flow1", "flow2"},
"files": []string{"file1", "file2"},
"workflow": []string{"flow1", "flow2"},
"knowledge": []string{"file1", "file2"},
"tools": []map[string]interface{}{{"name": "tool1"}, {"name": "tool2"}},
"permissions": map[string]interface{}{"read": true, "write": true},
"placeholder": map[string]interface{}{
@ -645,8 +649,8 @@ func TestXunAssistantCRUD(t *testing.T) {
"tags": nil,
"options": nil,
"prompts": nil,
"flows": nil,
"files": nil,
"workflow": nil,
"knowledge": nil,
"tools": nil,
"permissions": nil,
"placeholder": nil,
@ -668,8 +672,8 @@ func TestXunAssistantCRUD(t *testing.T) {
assert.Nil(t, assistant3Data["tags"])
assert.Nil(t, assistant3Data["options"])
assert.Nil(t, assistant3Data["prompts"])
assert.Nil(t, assistant3Data["flows"])
assert.Nil(t, assistant3Data["files"])
assert.Nil(t, assistant3Data["workflow"])
assert.Nil(t, assistant3Data["knowledge"])
assert.Nil(t, assistant3Data["tools"])
assert.Nil(t, assistant3Data["permissions"])
assert.Nil(t, assistant3Data["placeholder"])
@ -680,7 +684,7 @@ func TestXunAssistantCRUD(t *testing.T) {
nonExistentData, err := store.GetAssistant("non-existent-id")
assert.Error(t, err)
assert.Nil(t, nonExistentData)
assert.Contains(t, err.Error(), "not found")
assert.Contains(t, err.Error(), "is empty")
// Test GetAssistants to verify JSON fields are properly stored
resp, err := store.GetAssistants(AssistantFilter{})
@ -1246,3 +1250,463 @@ func TestXunGetChatsWithSilent(t *testing.T) {
}
assert.Equal(t, 3, totalNonSilentChats, "Non-silent filter should only return non-silent chats")
}
func TestXunAttachmentCRUD(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
defer capsule.Schema().DropTableIfExists("__unit_test_conversation_attachment")
// Drop attachment table before test
err := capsule.Schema().DropTableIfExists("__unit_test_conversation_attachment")
if err != nil {
t.Fatal(err)
}
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
store, err := NewXun(Setting{
Connector: "default",
Prefix: "__unit_test_conversation_",
})
if err != nil {
t.Fatal(err)
}
// Clean up any existing data
_, err = store.DeleteAttachments(AttachmentFilter{})
assert.Nil(t, err)
// Test SaveAttachment (Create)
attachment := map[string]interface{}{
"file_id": "test-file-123",
"uid": "user-123",
"manager": "local",
"content_type": "image/jpeg",
"name": "test-image.jpg",
"guest": false,
"public": true,
"gzip": false,
"bytes": 102400,
"scope": []string{"user", "admin"},
}
v, err := store.SaveAttachment(attachment)
assert.Nil(t, err)
fileID := v.(string)
assert.Equal(t, "test-file-123", fileID)
// Test GetAttachment
attachmentData, err := store.GetAttachment(fileID)
assert.Nil(t, err)
assert.NotNil(t, attachmentData)
assert.Equal(t, "test-file-123", attachmentData["file_id"])
assert.Equal(t, "user-123", attachmentData["uid"])
assert.Equal(t, "local", attachmentData["manager"])
assert.Equal(t, "image/jpeg", attachmentData["content_type"])
assert.Equal(t, "test-image.jpg", attachmentData["name"])
assert.Equal(t, int64(1), attachmentData["public"])
assert.Equal(t, []interface{}{"user", "admin"}, attachmentData["scope"])
// Test SaveAttachment (Update)
attachment["name"] = "updated-image.jpg"
attachment["bytes"] = 204800
v, err = store.SaveAttachment(attachment)
assert.Nil(t, err)
assert.Equal(t, "test-file-123", v.(string))
// Verify update
attachmentData, err = store.GetAttachment(fileID)
assert.Nil(t, err)
assert.Equal(t, "updated-image.jpg", attachmentData["name"])
assert.Equal(t, int64(204800), attachmentData["bytes"])
// Test GetAttachments with filters
resp, err := store.GetAttachments(AttachmentFilter{
UID: "user-123",
Manager: "local",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Equal(t, 1, len(resp.Data))
assert.Equal(t, "test-file-123", resp.Data[0]["file_id"])
// Test with non-existent file
nonExistentData, err := store.GetAttachment("non-existent-file")
assert.Error(t, err)
assert.Nil(t, nonExistentData)
assert.Contains(t, err.Error(), "is empty")
// Test DeleteAttachment
err = store.DeleteAttachment(fileID)
assert.Nil(t, err)
// Verify deletion
_, err = store.GetAttachment(fileID)
assert.Error(t, err)
}
func TestXunKnowledgeCRUD(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
defer capsule.Schema().DropTableIfExists("__unit_test_conversation_knowledge")
// Drop knowledge table before test
err := capsule.Schema().DropTableIfExists("__unit_test_conversation_knowledge")
if err != nil {
t.Fatal(err)
}
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
store, err := NewXun(Setting{
Connector: "default",
Prefix: "__unit_test_conversation_",
})
if err != nil {
t.Fatal(err)
}
// Clean up any existing data
_, err = store.DeleteKnowledges(KnowledgeFilter{})
assert.Nil(t, err)
// Test SaveKnowledge (Create)
knowledge := map[string]interface{}{
"collection_id": "test-collection-123",
"name": "Test Knowledge Collection",
"description": "A test knowledge collection for unit tests",
"uid": "user-123",
"public": true,
"readonly": false,
"system": false,
"sort": 100,
"cover": "cover-image.jpg",
"scope": []string{"user", "admin"},
"option": map[string]interface{}{"embedding": "openai", "chunk_size": 1000},
}
v, err := store.SaveKnowledge(knowledge)
assert.Nil(t, err)
collectionID := v.(string)
assert.Equal(t, "test-collection-123", collectionID)
// Test GetKnowledge
knowledgeData, err := store.GetKnowledge(collectionID)
assert.Nil(t, err)
assert.NotNil(t, knowledgeData)
assert.Equal(t, "test-collection-123", knowledgeData["collection_id"])
assert.Equal(t, "Test Knowledge Collection", knowledgeData["name"])
assert.Equal(t, "A test knowledge collection for unit tests", knowledgeData["description"])
assert.Equal(t, "user-123", knowledgeData["uid"])
assert.Equal(t, int64(1), knowledgeData["public"])
assert.Equal(t, int64(100), knowledgeData["sort"])
assert.Equal(t, []interface{}{"user", "admin"}, knowledgeData["scope"])
assert.Equal(t, map[string]interface{}{"embedding": "openai", "chunk_size": float64(1000)}, knowledgeData["option"])
// Test SaveKnowledge (Update)
knowledge["name"] = "Updated Knowledge Collection"
knowledge["description"] = "Updated description"
knowledge["sort"] = 200
v, err = store.SaveKnowledge(knowledge)
assert.Nil(t, err)
assert.Equal(t, "test-collection-123", v.(string))
// Verify update
knowledgeData, err = store.GetKnowledge(collectionID)
assert.Nil(t, err)
assert.Equal(t, "Updated Knowledge Collection", knowledgeData["name"])
assert.Equal(t, "Updated description", knowledgeData["description"])
assert.Equal(t, int64(200), knowledgeData["sort"])
// Test knowledge without sort field (should get default value 9999)
knowledgeWithoutSort := map[string]interface{}{
"collection_id": "test-collection-456",
"name": "Test Knowledge Without Sort",
"description": "Test knowledge without explicit sort value",
"uid": "user-123",
}
v2, err := store.SaveKnowledge(knowledgeWithoutSort)
assert.Nil(t, err)
// Verify default sort value
knowledgeData2, err := store.GetKnowledge(v2.(string))
assert.Nil(t, err)
assert.Equal(t, int64(9999), knowledgeData2["sort"])
// Test GetKnowledges with filters
resp, err := store.GetKnowledges(KnowledgeFilter{
UID: "user-123",
Keywords: "Updated",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Equal(t, 1, len(resp.Data))
assert.Equal(t, "test-collection-123", resp.Data[0]["collection_id"])
// Test with non-existent collection
nonExistentData, err := store.GetKnowledge("non-existent-collection")
assert.Error(t, err)
assert.Nil(t, nonExistentData)
assert.Contains(t, err.Error(), "is empty")
// Test DeleteKnowledge
err = store.DeleteKnowledge(collectionID)
assert.Nil(t, err)
err = store.DeleteKnowledge(v2.(string))
assert.Nil(t, err)
// Verify deletion
_, err = store.GetKnowledge(collectionID)
assert.Error(t, err)
}
func TestXunKnowledgeFiltering(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
defer capsule.Schema().DropTableIfExists("__unit_test_conversation_knowledge")
// Drop knowledge table before test
err := capsule.Schema().DropTableIfExists("__unit_test_conversation_knowledge")
if err != nil {
t.Fatal(err)
}
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
store, err := NewXun(Setting{
Connector: "default",
Prefix: "__unit_test_conversation_",
})
if err != nil {
t.Fatal(err)
}
// Create test data for filtering tests
testKnowledges := []map[string]interface{}{}
for i := 0; i < 15; i++ {
knowledge := map[string]interface{}{
"collection_id": fmt.Sprintf("test-collection-%d", i),
"name": fmt.Sprintf("Collection %d", i),
"description": fmt.Sprintf("Description for collection %d", i),
"uid": fmt.Sprintf("user-%d", i%3),
"public": i%2 == 0,
"readonly": i%3 == 0,
"system": i%4 == 0,
"sort": 100 + i*10, // Different sort values for testing ordering
"cover": fmt.Sprintf("cover%d.jpg", i),
}
id, err := store.SaveKnowledge(knowledge)
assert.Nil(t, err)
knowledge["collection_id"] = id
testKnowledges = append(testKnowledges, knowledge)
}
// Test sorting functionality - should return results ordered by sort ASC then created_at DESC
respAll, err := store.GetKnowledges(KnowledgeFilter{
Page: 1,
PageSize: 15,
})
assert.Nil(t, err)
assert.Equal(t, 15, len(respAll.Data))
// Verify sort order - first item should have the smallest sort value
firstSort := respAll.Data[0]["sort"].(int64)
lastSort := respAll.Data[len(respAll.Data)-1]["sort"].(int64)
assert.LessOrEqual(t, firstSort, lastSort, "Results should be ordered by sort ASC")
// More specific sort order verification
for i := 1; i < len(respAll.Data); i++ {
prevSort := respAll.Data[i-1]["sort"].(int64)
currSort := respAll.Data[i]["sort"].(int64)
assert.LessOrEqual(t, prevSort, currSort, "Sort order should be ascending")
}
// Test filtering by UID
resp, err := store.GetKnowledges(KnowledgeFilter{
UID: "user-0",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by public status
publicTrue := true
resp, err = store.GetKnowledges(KnowledgeFilter{
Public: &publicTrue,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by readonly status
readonlyTrue := true
resp, err = store.GetKnowledges(KnowledgeFilter{
Readonly: &readonlyTrue,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by system status
systemTrue := true
resp, err = store.GetKnowledges(KnowledgeFilter{
System: &systemTrue,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by keywords
resp, err = store.GetKnowledges(KnowledgeFilter{
Keywords: "Collection 1",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test DeleteKnowledges with filter
count, err := store.DeleteKnowledges(KnowledgeFilter{
UID: "user-0",
})
assert.Nil(t, err)
assert.Greater(t, count, int64(0))
// Verify deletion
resp, err = store.GetKnowledges(KnowledgeFilter{
UID: "user-0",
})
assert.Nil(t, err)
assert.Equal(t, 0, len(resp.Data))
// Clean up all test data
_, err = store.DeleteKnowledges(KnowledgeFilter{})
assert.Nil(t, err)
}
func TestXunAttachmentFiltering(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
defer capsule.Schema().DropTableIfExists("__unit_test_conversation_attachment")
// Drop attachment table before test
err := capsule.Schema().DropTableIfExists("__unit_test_conversation_attachment")
if err != nil {
t.Fatal(err)
}
// Add a small delay to ensure table is created
time.Sleep(100 * time.Millisecond)
store, err := NewXun(Setting{
Connector: "default",
Prefix: "__unit_test_conversation_",
})
if err != nil {
t.Fatal(err)
}
// Create test data for filtering tests
testAttachments := []map[string]interface{}{}
for i := 0; i < 15; i++ {
attachment := map[string]interface{}{
"file_id": fmt.Sprintf("test-file-%d", i),
"uid": fmt.Sprintf("user-%d", i%3),
"manager": fmt.Sprintf("manager%d", i%2),
"content_type": fmt.Sprintf("type/%d", i%4),
"name": fmt.Sprintf("file%d.txt", i),
"guest": i%2 == 0,
"public": i%3 == 0,
"gzip": i%4 == 0,
"bytes": 1024 * (i + 1),
"collection_id": fmt.Sprintf("collection-%d", i%5),
}
id, err := store.SaveAttachment(attachment)
assert.Nil(t, err)
attachment["file_id"] = id
testAttachments = append(testAttachments, attachment)
}
// Test filtering by UID
resp, err := store.GetAttachments(AttachmentFilter{
UID: "user-0",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by manager
resp, err = store.GetAttachments(AttachmentFilter{
Manager: "manager0",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by content_type
resp, err = store.GetAttachments(AttachmentFilter{
ContentType: "type/0",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by guest status
guestTrue := true
resp, err = store.GetAttachments(AttachmentFilter{
Guest: &guestTrue,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by public status
publicTrue := true
resp, err = store.GetAttachments(AttachmentFilter{
Public: &publicTrue,
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test filtering by keywords
resp, err = store.GetAttachments(AttachmentFilter{
Keywords: "file1",
Page: 1,
PageSize: 10,
})
assert.Nil(t, err)
assert.Greater(t, len(resp.Data), 0)
// Test DeleteAttachments with filter
count, err := store.DeleteAttachments(AttachmentFilter{
Manager: "manager0",
})
assert.Nil(t, err)
assert.Greater(t, count, int64(0))
// Verify deletion
resp, err = store.GetAttachments(AttachmentFilter{
Manager: "manager0",
})
assert.Nil(t, err)
assert.Equal(t, 0, len(resp.Data))
// Clean up all test data
_, err = store.DeleteAttachments(AttachmentFilter{})
assert.Nil(t, err)
}