mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
6acdd191c7
commit bcdb026ae43e4f95d0b2c4f9bd440a2df9d6b514 Author: Ran <16112591+chen-ran@users.noreply.github.com> Date: Thu Feb 12 17:10:32 2026 +0800 chore: update DEVELOPMENT.md commit30281742efMerge:ca5c6a15b05f13Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Thu Feb 12 15:49:17 2026 +0800 merge(github/main): integrate fx dependency injection framework Merge upstream fx refactor and adapt all services to use go.uber.org/fx for dependency injection. Resolve conflicts in main.go, server.go, and service constructors while preserving our domain model changes. - Fix telegram adapter panic on shutdown (double close channel) - Fix feishu adapter processing messages after stop - Increase directory lookup timeout from 2s to 5s commitca5c6a1866Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Thu Feb 12 15:33:09 2026 +0800 refactor(core): restructure conversation, channel and message domains - Rename chat module to conversation with flow-based architecture - Move channelidentities into channel/identities subpackage - Add channel/route for routing logic - Add message service with event hub - Add MCP providers: container, directory, schedule - Refactor Feishu/Telegram adapters with directory and stream support - Add platform management page and channel badges in web UI - Update database schema for conversations, messages and channel routes - Add @memoh/shared package for cross-package type definitions commit75e2ef0467Merge:d99ba3801cb6c8Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Thu Feb 12 14:45:49 2026 +0800 merge(github): merge github/main, resolve index.ts URL conflict Keep our defensive absolute-URL check in createAuthFetcher. commitd99ba38b7dMerge:860e20f35ce7d1Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Thu Feb 12 05:20:18 2026 +0800 merge(github): merge github/main, keep our code and docs/spec commit860e20fe70Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Wed Feb 11 22:13:27 2026 +0800 docs(docs): add concepts and style guides for VitePress site - Add concepts: identity-and-binding, index (en/zh) - Add style: terminology (en/zh) - Update index and zh/index - Update .vitepress/config.ts commita75fdb8040Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Wed Feb 11 17:37:16 2026 +0800 refactor(mcp): standardize unified tool gateway on go-sdk Split business executors from federation sources and migrate unified tool/federation transports to the official go-sdk for stricter MCP compliance and safer session lifecycle handling. Add targeted regression tests for accept compatibility, initialization retries, pending cleanup, and include updated swagger artifacts. commit02b33c8e85Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Wed Feb 11 15:42:21 2026 +0800 refactor(core): finalize user-centric identity and policy cleanup Unify auth and chat identity semantics around user_id, enforce personal-bot owner-only authorization, and remove legacy compatibility branches in integration tests. commit06e8619a37Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Wed Feb 11 14:47:03 2026 +0800 refactor(core): migrate channel identity and binding across app Align channel identity and bind flow across backend and app-facing layers, including generated swagger artifacts and package lock updates while excluding docs content changes.
401 lines
12 KiB
Go
401 lines
12 KiB
Go
package models
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/memohai/memoh/internal/db"
|
|
"github.com/memohai/memoh/internal/db/sqlc"
|
|
)
|
|
|
|
// Service provides CRUD operations for models
|
|
type Service struct {
|
|
queries *sqlc.Queries
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewService creates a new models service
|
|
func NewService(log *slog.Logger, queries *sqlc.Queries) *Service {
|
|
return &Service{
|
|
queries: queries,
|
|
logger: log.With(slog.String("service", "models")),
|
|
}
|
|
}
|
|
|
|
// Create adds a new model to the database
|
|
func (s *Service) Create(ctx context.Context, req AddRequest) (AddResponse, error) {
|
|
model := Model(req)
|
|
if err := model.Validate(); err != nil {
|
|
return AddResponse{}, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
// Convert to sqlc params
|
|
llmProviderID, err := db.ParseUUID(model.LlmProviderID)
|
|
if err != nil {
|
|
return AddResponse{}, fmt.Errorf("invalid llm provider ID: %w", err)
|
|
}
|
|
|
|
params := sqlc.CreateModelParams{
|
|
ModelID: model.ModelID,
|
|
LlmProviderID: llmProviderID,
|
|
IsMultimodal: model.IsMultimodal,
|
|
Type: string(model.Type),
|
|
}
|
|
|
|
// Handle optional name field
|
|
if model.Name != "" {
|
|
params.Name = pgtype.Text{String: model.Name, Valid: true}
|
|
}
|
|
|
|
// Handle optional dimensions field (only for embedding models)
|
|
if model.Type == ModelTypeEmbedding && model.Dimensions > 0 {
|
|
params.Dimensions = pgtype.Int4{Int32: int32(model.Dimensions), Valid: true}
|
|
}
|
|
|
|
created, err := s.queries.CreateModel(ctx, params)
|
|
if err != nil {
|
|
return AddResponse{}, fmt.Errorf("failed to create model: %w", err)
|
|
}
|
|
|
|
// Convert pgtype.UUID to string
|
|
var idStr string
|
|
if created.ID.Valid {
|
|
id, err := uuid.FromBytes(created.ID.Bytes[:])
|
|
if err != nil {
|
|
return AddResponse{}, fmt.Errorf("failed to convert UUID: %w", err)
|
|
}
|
|
idStr = id.String()
|
|
}
|
|
|
|
return AddResponse{
|
|
ID: idStr,
|
|
ModelID: created.ModelID,
|
|
}, nil
|
|
}
|
|
|
|
// GetByID retrieves a model by its internal UUID
|
|
func (s *Service) GetByID(ctx context.Context, id string) (GetResponse, error) {
|
|
uuid, err := db.ParseUUID(id)
|
|
if err != nil {
|
|
return GetResponse{}, fmt.Errorf("invalid ID: %w", err)
|
|
}
|
|
|
|
dbModel, err := s.queries.GetModelByID(ctx, uuid)
|
|
if err != nil {
|
|
return GetResponse{}, fmt.Errorf("failed to get model: %w", err)
|
|
}
|
|
|
|
return convertToGetResponse(dbModel), nil
|
|
}
|
|
|
|
// GetByModelID retrieves a model by its model_id field
|
|
func (s *Service) GetByModelID(ctx context.Context, modelID string) (GetResponse, error) {
|
|
if modelID == "" {
|
|
return GetResponse{}, fmt.Errorf("model_id is required")
|
|
}
|
|
|
|
dbModel, err := s.queries.GetModelByModelID(ctx, modelID)
|
|
if err != nil {
|
|
return GetResponse{}, fmt.Errorf("failed to get model: %w", err)
|
|
}
|
|
|
|
return convertToGetResponse(dbModel), nil
|
|
}
|
|
|
|
// List returns all models
|
|
func (s *Service) List(ctx context.Context) ([]GetResponse, error) {
|
|
dbModels, err := s.queries.ListModels(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list models: %w", err)
|
|
}
|
|
|
|
return convertToGetResponseList(dbModels), nil
|
|
}
|
|
|
|
// ListByType returns models filtered by type (chat or embedding)
|
|
func (s *Service) ListByType(ctx context.Context, modelType ModelType) ([]GetResponse, error) {
|
|
if modelType != ModelTypeChat && modelType != ModelTypeEmbedding {
|
|
return nil, fmt.Errorf("invalid model type: %s", modelType)
|
|
}
|
|
|
|
dbModels, err := s.queries.ListModelsByType(ctx, string(modelType))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list models by type: %w", err)
|
|
}
|
|
|
|
return convertToGetResponseList(dbModels), nil
|
|
}
|
|
|
|
// ListByClientType returns models filtered by client type
|
|
func (s *Service) ListByClientType(ctx context.Context, clientType ClientType) ([]GetResponse, error) {
|
|
if !isValidClientType(clientType) {
|
|
return nil, fmt.Errorf("invalid client type: %s", clientType)
|
|
}
|
|
|
|
dbModels, err := s.queries.ListModelsByClientType(ctx, string(clientType))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list models by client type: %w", err)
|
|
}
|
|
|
|
return convertToGetResponseList(dbModels), nil
|
|
}
|
|
|
|
// ListByProviderID returns models filtered by provider ID.
|
|
func (s *Service) ListByProviderID(ctx context.Context, providerID string) ([]GetResponse, error) {
|
|
if strings.TrimSpace(providerID) == "" {
|
|
return nil, fmt.Errorf("provider id is required")
|
|
}
|
|
uuid, err := db.ParseUUID(providerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid provider id: %w", err)
|
|
}
|
|
dbModels, err := s.queries.ListModelsByProviderID(ctx, uuid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list models by provider: %w", err)
|
|
}
|
|
return convertToGetResponseList(dbModels), nil
|
|
}
|
|
|
|
// ListByProviderIDAndType returns models filtered by provider ID and type.
|
|
func (s *Service) ListByProviderIDAndType(ctx context.Context, providerID string, modelType ModelType) ([]GetResponse, error) {
|
|
if modelType != ModelTypeChat && modelType != ModelTypeEmbedding {
|
|
return nil, fmt.Errorf("invalid model type: %s", modelType)
|
|
}
|
|
if strings.TrimSpace(providerID) == "" {
|
|
return nil, fmt.Errorf("provider id is required")
|
|
}
|
|
uuid, err := db.ParseUUID(providerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid provider id: %w", err)
|
|
}
|
|
dbModels, err := s.queries.ListModelsByProviderIDAndType(ctx, sqlc.ListModelsByProviderIDAndTypeParams{
|
|
LlmProviderID: uuid,
|
|
Type: string(modelType),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list models by provider and type: %w", err)
|
|
}
|
|
return convertToGetResponseList(dbModels), nil
|
|
}
|
|
|
|
// UpdateByID updates a model by its internal UUID
|
|
func (s *Service) UpdateByID(ctx context.Context, id string, req UpdateRequest) (GetResponse, error) {
|
|
uuid, err := db.ParseUUID(id)
|
|
if err != nil {
|
|
return GetResponse{}, fmt.Errorf("invalid ID: %w", err)
|
|
}
|
|
|
|
model := Model(req)
|
|
if err := model.Validate(); err != nil {
|
|
return GetResponse{}, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
params := sqlc.UpdateModelParams{
|
|
ID: uuid,
|
|
IsMultimodal: model.IsMultimodal,
|
|
Type: string(model.Type),
|
|
}
|
|
|
|
llmProviderID, err := db.ParseUUID(model.LlmProviderID)
|
|
if err != nil {
|
|
return GetResponse{}, fmt.Errorf("invalid llm provider ID: %w", err)
|
|
}
|
|
params.LlmProviderID = llmProviderID
|
|
|
|
if model.Name != "" {
|
|
params.Name = pgtype.Text{String: model.Name, Valid: true}
|
|
}
|
|
|
|
if model.Type == ModelTypeEmbedding && model.Dimensions > 0 {
|
|
params.Dimensions = pgtype.Int4{Int32: int32(model.Dimensions), Valid: true}
|
|
}
|
|
|
|
updated, err := s.queries.UpdateModel(ctx, params)
|
|
if err != nil {
|
|
return GetResponse{}, fmt.Errorf("failed to update model: %w", err)
|
|
}
|
|
|
|
return convertToGetResponse(updated), nil
|
|
}
|
|
|
|
// UpdateByModelID updates a model by its model_id field
|
|
func (s *Service) UpdateByModelID(ctx context.Context, modelID string, req UpdateRequest) (GetResponse, error) {
|
|
if modelID == "" {
|
|
return GetResponse{}, fmt.Errorf("model_id is required")
|
|
}
|
|
|
|
model := Model(req)
|
|
if err := model.Validate(); err != nil {
|
|
return GetResponse{}, fmt.Errorf("validation failed: %w", err)
|
|
}
|
|
|
|
params := sqlc.UpdateModelByModelIDParams{
|
|
ModelID: modelID,
|
|
IsMultimodal: model.IsMultimodal,
|
|
Type: string(model.Type),
|
|
}
|
|
|
|
llmProviderID, err := db.ParseUUID(model.LlmProviderID)
|
|
if err != nil {
|
|
return GetResponse{}, fmt.Errorf("invalid llm provider ID: %w", err)
|
|
}
|
|
params.LlmProviderID = llmProviderID
|
|
|
|
if model.Name != "" {
|
|
params.Name = pgtype.Text{String: model.Name, Valid: true}
|
|
}
|
|
|
|
if model.Type == ModelTypeEmbedding && model.Dimensions > 0 {
|
|
params.Dimensions = pgtype.Int4{Int32: int32(model.Dimensions), Valid: true}
|
|
}
|
|
|
|
updated, err := s.queries.UpdateModelByModelID(ctx, params)
|
|
if err != nil {
|
|
return GetResponse{}, fmt.Errorf("failed to update model: %w", err)
|
|
}
|
|
|
|
return convertToGetResponse(updated), nil
|
|
}
|
|
|
|
// DeleteByID deletes a model by its internal UUID
|
|
func (s *Service) DeleteByID(ctx context.Context, id string) error {
|
|
uuid, err := db.ParseUUID(id)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid ID: %w", err)
|
|
}
|
|
|
|
if err := s.queries.DeleteModel(ctx, uuid); err != nil {
|
|
return fmt.Errorf("failed to delete model: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteByModelID deletes a model by its model_id field
|
|
func (s *Service) DeleteByModelID(ctx context.Context, modelID string) error {
|
|
if modelID == "" {
|
|
return fmt.Errorf("model_id is required")
|
|
}
|
|
|
|
if err := s.queries.DeleteModelByModelID(ctx, modelID); err != nil {
|
|
return fmt.Errorf("failed to delete model: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Count returns the total number of models
|
|
func (s *Service) Count(ctx context.Context) (int64, error) {
|
|
count, err := s.queries.CountModels(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to count models: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// CountByType returns the number of models of a specific type
|
|
func (s *Service) CountByType(ctx context.Context, modelType ModelType) (int64, error) {
|
|
if modelType != ModelTypeChat && modelType != ModelTypeEmbedding {
|
|
return 0, fmt.Errorf("invalid model type: %s", modelType)
|
|
}
|
|
|
|
count, err := s.queries.CountModelsByType(ctx, string(modelType))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to count models by type: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func convertToGetResponse(dbModel sqlc.Model) GetResponse {
|
|
resp := GetResponse{
|
|
ModelId: dbModel.ModelID,
|
|
Model: Model{
|
|
ModelID: dbModel.ModelID,
|
|
IsMultimodal: dbModel.IsMultimodal,
|
|
Input: modelInputFromMultimodal(dbModel.IsMultimodal),
|
|
Type: ModelType(dbModel.Type),
|
|
},
|
|
}
|
|
|
|
if dbModel.LlmProviderID.Valid {
|
|
resp.Model.LlmProviderID = dbModel.LlmProviderID.String()
|
|
}
|
|
|
|
if dbModel.Name.Valid {
|
|
resp.Model.Name = dbModel.Name.String
|
|
}
|
|
|
|
if dbModel.Dimensions.Valid {
|
|
resp.Model.Dimensions = int(dbModel.Dimensions.Int32)
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func convertToGetResponseList(dbModels []sqlc.Model) []GetResponse {
|
|
responses := make([]GetResponse, 0, len(dbModels))
|
|
for _, dbModel := range dbModels {
|
|
responses = append(responses, convertToGetResponse(dbModel))
|
|
}
|
|
return responses
|
|
}
|
|
|
|
// modelInputFromMultimodal builds the input list based on multimodal support.
|
|
func modelInputFromMultimodal(isMultimodal bool) []string {
|
|
if isMultimodal {
|
|
return []string{ModelInputText, ModelInputImage}
|
|
}
|
|
return []string{ModelInputText}
|
|
}
|
|
|
|
func isValidClientType(clientType ClientType) bool {
|
|
switch clientType {
|
|
case ClientTypeOpenAI,
|
|
ClientTypeAnthropic,
|
|
ClientTypeGoogle,
|
|
ClientTypeBedrock,
|
|
ClientTypeOllama,
|
|
ClientTypeAzure,
|
|
ClientTypeDashscope,
|
|
ClientTypeOther:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// SelectMemoryModel selects a chat model for memory operations.
|
|
func SelectMemoryModel(ctx context.Context, modelsService *Service, queries *sqlc.Queries) (GetResponse, sqlc.LlmProvider, error) {
|
|
if modelsService == nil {
|
|
return GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("models service not configured")
|
|
}
|
|
candidates, err := modelsService.ListByType(ctx, ModelTypeChat)
|
|
if err != nil || len(candidates) == 0 {
|
|
return GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("no chat models available for memory operations")
|
|
}
|
|
selected := candidates[0]
|
|
provider, err := FetchProviderByID(ctx, queries, selected.LlmProviderID)
|
|
if err != nil {
|
|
return GetResponse{}, sqlc.LlmProvider{}, err
|
|
}
|
|
return selected, provider, nil
|
|
}
|
|
|
|
// FetchProviderByID fetches a provider by ID.
|
|
func FetchProviderByID(ctx context.Context, queries *sqlc.Queries, providerID string) (sqlc.LlmProvider, error) {
|
|
if strings.TrimSpace(providerID) == "" {
|
|
return sqlc.LlmProvider{}, fmt.Errorf("provider id missing")
|
|
}
|
|
parsed, err := db.ParseUUID(providerID)
|
|
if err != nil {
|
|
return sqlc.LlmProvider{}, err
|
|
}
|
|
return queries.GetLlmProviderByID(ctx, parsed)
|
|
}
|