diff --git a/backend/cmd/dip.go b/backend/cmd/dip.go deleted file mode 100644 index 746aecd..0000000 --- a/backend/cmd/dip.go +++ /dev/null @@ -1,105 +0,0 @@ -package cmd - -import ( - "time" - - "trankilou.fr/lassistanoque/backend/internal/adapter/auth/password" - "trankilou.fr/lassistanoque/backend/internal/adapter/database" - "trankilou.fr/lassistanoque/backend/internal/adapter/file" - "trankilou.fr/lassistanoque/backend/internal/adapter/llm" - "trankilou.fr/lassistanoque/backend/internal/adapter/security" - "trankilou.fr/lassistanoque/backend/internal/adapter/tools/weather" - "trankilou.fr/lassistanoque/backend/internal/domain" - "trankilou.fr/lassistanoque/backend/internal/service/auth" - "trankilou.fr/lassistanoque/backend/internal/service/chat" - "trankilou.fr/lassistanoque/backend/internal/service/storage" -) - -var db database.Database -var store storage.StorageProvider -var userRepository domain.UserRepository -var providerRepository domain.ProviderRepository -var agentRepository domain.AgentRepository -var chatRepository domain.ChatRepository -var settingsRepository domain.SettingsRepository -var authenticators = make(map[string]auth.Authenticator) -var tokenManager auth.TokenManager -var llmengine domain.LLMEngine - -func init() { - var err error - - db, err := database.GetDatabase() - if err != nil { - panic(err) - } - - err = db.Migrate() - if err != nil { - panic(err) - } - - // repositories - userRepository = db.UserRepository() - providerRepository = db.ProviderRepository() - agentRepository = db.AgentRepository() - chatRepository = db.ChatRepository() - settingsRepository = db.SettingsRepository() - - // storage - store, err = file.GetStorageProvider(db.FileRepository()) - if err != nil { - panic(err) - } - - // authentication - tokenManager = security.NewJwtTokenManager(12*time.Hour, 7*24*time.Hour, "lassistanoque") - pwdAuth := password.NewPasswordAuthenticator(tokenManager, GetUserRepository()) - authenticators["password"] = pwdAuth - - // llm - llmengine = llm.NewAnyLLMEngine() - - // llm tools - chat.RegisterTool(&weather.WeatherTool{}) -} - -func GetStorageProvider() storage.StorageProvider { - return store -} - -func GetUserRepository() domain.UserRepository { - return userRepository -} - -func GetProviderRepository() domain.ProviderRepository { - return providerRepository -} - -func GetAgentRepository() domain.AgentRepository { - return agentRepository -} - -func GetChatRepository() domain.ChatRepository { - return chatRepository -} - -func GetSettingsRepository() domain.SettingsRepository { - return settingsRepository -} - -func GetAuthenticators() map[string]auth.Authenticator { - return authenticators -} - -func GetTokenManager() auth.TokenManager { - return tokenManager -} - -func GetLLMEngine() domain.LLMEngine { - return llmengine -} - -func Close() { - db.Close() -} diff --git a/backend/cmd/ioc.go b/backend/cmd/ioc.go new file mode 100644 index 0000000..8e9db7e --- /dev/null +++ b/backend/cmd/ioc.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "time" + + "trankilou.fr/lassistanoque/backend/internal/adapter/auth/password" + "trankilou.fr/lassistanoque/backend/internal/adapter/database" + "trankilou.fr/lassistanoque/backend/internal/adapter/file" + "trankilou.fr/lassistanoque/backend/internal/adapter/llm" + "trankilou.fr/lassistanoque/backend/internal/adapter/security" + "trankilou.fr/lassistanoque/backend/internal/adapter/tools/searxng" + "trankilou.fr/lassistanoque/backend/internal/adapter/tools/weather" + "trankilou.fr/lassistanoque/backend/internal/domain" + "trankilou.fr/lassistanoque/backend/internal/service/agent" + "trankilou.fr/lassistanoque/backend/internal/service/auth" + "trankilou.fr/lassistanoque/backend/internal/service/chat" + "trankilou.fr/lassistanoque/backend/internal/service/provider" + "trankilou.fr/lassistanoque/backend/internal/service/storage" + "trankilou.fr/lassistanoque/backend/internal/service/tool" + "trankilou.fr/lassistanoque/backend/internal/service/user" +) + +var db database.Database + +// adapters +var storeAdapter storage.StorageProvider +var llmengine domain.LLMEngine + +// services +var authService *auth.Service +var userService *user.Service +var storageService *storage.Service +var providerService *provider.Service +var agentService *agent.Service +var toolService *tool.Service +var chatService *chat.Service +var tokenManager auth.TokenManager + +func init() { + var err error + + db, err = database.GetDatabase() + if err != nil { + panic(err) + } + + err = db.Migrate() + if err != nil { + panic(err) + } +} + +func Close() { + db.Close() +} + +// Adapters +func getStoreAdapter() storage.StorageProvider { + if storeAdapter == nil { + var err error + storeAdapter, err = file.GetStorageProvider(db.FileRepository()) + if err != nil { + panic(err) + } + } + return storeAdapter +} + +func getAuthenticators() map[string]auth.Authenticator { + authenticators := make(map[string]auth.Authenticator) + pwdAuth := password.NewPasswordAuthenticator(GetTokenManager(), db.UserRepository()) + authenticators["password"] = pwdAuth + return authenticators +} + +func GetTokenManager() auth.TokenManager { + if tokenManager == nil { + tokenManager = security.NewJwtTokenManager(12*time.Hour, 7*24*time.Hour, "lassistanoque") + } + return tokenManager +} + +func GetLLMAdapter() domain.LLMEngine { + if llmengine == nil { + llmengine = llm.NewAnyLLMEngine() + } + return llmengine +} + +// Services +func GetStorageService() *storage.Service { + if storageService == nil { + storageService = storage.NewService(getStoreAdapter()) + } + return storageService +} + +func GetAuthService() *auth.Service { + if authService == nil { + authService = auth.NewService(db.SettingsRepository(), db.UserRepository(), getAuthenticators()) + } + return authService +} + +func GetUserService() *user.Service { + if userService == nil { + userService = user.NewService(db.UserRepository()) + } + return userService +} +func GetProviderService() *provider.Service { + if providerService == nil { + providerService = provider.NewService(db.ProviderRepository(), db.UserRepository(), GetLLMAdapter()) + } + return providerService +} + +func GetAgentService() *agent.Service { + if agentService == nil { + agentService = agent.NewService(db.AgentRepository(), db.UserRepository()) + } + return agentService +} +func GetChatService() *chat.Service { + if chatService == nil { + chatService = chat.NewService( + db.UserRepository(), + db.AgentRepository(), + db.ProviderRepository(), + db.ChatRepository(), + GetLLMAdapter(), + GetToolService(), + ) + } + return chatService +} + +func GetToolService() *tool.Service { + if toolService == nil { + toolService = tool.NewService(db.ToolRepository(), db.UserRepository()) + toolService.RegisterToolImpl(&weather.WeatherTool{}) + toolService.RegisterToolImpl(&searxng.SearxngTool{}) + } + return toolService +} diff --git a/backend/cmd/serve.go b/backend/cmd/serve.go index 44d3dcf..19477ce 100644 --- a/backend/cmd/serve.go +++ b/backend/cmd/serve.go @@ -11,14 +11,8 @@ import ( "time" "github.com/spf13/cobra" - "trankilou.fr/lassistanoque/backend/internal/brain" + "trankilou.fr/lassistanoque/backend/internal/gateway" "trankilou.fr/lassistanoque/backend/internal/http" - "trankilou.fr/lassistanoque/backend/internal/service/agent" - "trankilou.fr/lassistanoque/backend/internal/service/auth" - "trankilou.fr/lassistanoque/backend/internal/service/chat" - "trankilou.fr/lassistanoque/backend/internal/service/provider" - "trankilou.fr/lassistanoque/backend/internal/service/storage" - "trankilou.fr/lassistanoque/backend/internal/service/user" ) func init() { @@ -44,35 +38,26 @@ func runServe() { defer Close() - // services - authService := auth.NewService(GetSettingsRepository(), GetUserRepository(), GetAuthenticators()) - userService := user.NewService(GetUserRepository()) - storageService := storage.NewService(GetStorageProvider()) - providerService := provider.NewService(GetProviderRepository(), GetUserRepository(), GetLLMEngine()) - agentService := agent.NewService(GetAgentRepository(), GetUserRepository()) - chatService := chat.NewService(GetUserRepository(), GetAgentRepository(), GetProviderRepository(), GetChatRepository(), GetLLMEngine()) - // http server httpRouter := http.NewRouter(http.Dependencies{ - StorageService: storageService, - AuthService: authService, - UserService: userService, - ProviderService: providerService, - AgentService: agentService, - ChatService: chatService, + StorageService: GetStorageService(), + AuthService: GetAuthService(), + UserService: GetUserService(), + ProviderService: GetProviderService(), + AgentService: GetAgentService(), + ChatService: GetChatService(), + ToolService: GetToolService(), TokenManager: GetTokenManager(), }) - brainRouter := brain.NewRouter(brain.Dependencies{ - ChatRepository: GetChatRepository(), - }) + gatewayRouter := gateway.NewRouter(gateway.Dependencies{}) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // start Brain router go func() { - if err := brainRouter.Start(); err != nil && !errors.Is(err, nethttp.ErrServerClosed) { + if err := gatewayRouter.Start(); err != nil && !errors.Is(err, nethttp.ErrServerClosed) { slog.Error("failed to start llm server", "error", err) } }() @@ -97,7 +82,7 @@ func runServe() { // stop LLM service ctx, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) defer cancel2() - if err := brainRouter.Stop(ctx); err != nil { + if err := gatewayRouter.Stop(ctx); err != nil { slog.Error("failed to stop llm server", "error", err) } slog.Info("LLM server stopped gracefully") diff --git a/backend/internal/adapter/database/factory.go b/backend/internal/adapter/database/factory.go index 0d20214..fde0f84 100644 --- a/backend/internal/adapter/database/factory.go +++ b/backend/internal/adapter/database/factory.go @@ -15,6 +15,7 @@ type Database interface { ProviderRepository() domain.ProviderRepository AgentRepository() domain.AgentRepository ChatRepository() domain.ChatRepository + ToolRepository() domain.ToolRepository Migrate() error Close() } diff --git a/backend/internal/adapter/database/turso/db.go b/backend/internal/adapter/database/turso/db.go index 726ab35..5b46ece 100644 --- a/backend/internal/adapter/database/turso/db.go +++ b/backend/internal/adapter/database/turso/db.go @@ -79,3 +79,7 @@ func (db *TursoDB) AgentRepository() domain.AgentRepository { func (db *TursoDB) ChatRepository() domain.ChatRepository { return NewTursoChatRepository(db.DB) } + +func (db *TursoDB) ToolRepository() domain.ToolRepository { + return NewTursoToolRepository(db.DB) +} diff --git a/backend/internal/adapter/database/turso/migrations/000001_create_tables.up.sql b/backend/internal/adapter/database/turso/migrations/000001_create_tables.up.sql index f9317a2..703c0ec 100644 --- a/backend/internal/adapter/database/turso/migrations/000001_create_tables.up.sql +++ b/backend/internal/adapter/database/turso/migrations/000001_create_tables.up.sql @@ -95,9 +95,8 @@ create table tools ( id text not null primary key, team_id text not null, name text not null, - type text not null, enabled numeric not null default 1, - configuration text not null default '{}', + configuration text not null default '', _date_created numeric not null default current_timestamp, _date_updated numeric, _version text not null diff --git a/backend/internal/adapter/database/turso/tool_repository.go b/backend/internal/adapter/database/turso/tool_repository.go new file mode 100644 index 0000000..37393cc --- /dev/null +++ b/backend/internal/adapter/database/turso/tool_repository.go @@ -0,0 +1,72 @@ +package turso + +import ( + "database/sql" + "log" + + "trankilou.fr/lassistanoque/backend/internal/adapter/database/orm" + "trankilou.fr/lassistanoque/backend/internal/domain" +) + +type TursoToolRepository struct { + db *sql.DB + ToolTable orm.Table[domain.Tool] +} + +func NewTursoToolRepository(db *sql.DB) *TursoToolRepository { + dialect := orm.NewSqliteDialect() + ToolTable, err := orm.NewTable[domain.Tool](db, dialect, "tools") + if err != nil { + log.Fatalf("error creating lasebuche Tool table") + } + + return &TursoToolRepository{ + db: db, + ToolTable: ToolTable, + } +} + +func (r *TursoToolRepository) ListTools(userID string, teamID string) ([]*domain.Tool, error) { + return r.ToolTable.Select( + orm.WithWhere( + "team_id=$1 and team_id in (select team_id from user_teams where user_id=$2)", + teamID, + userID, + ), + ) +} + +func (r *TursoToolRepository) GetToolByName(userID string, teamID string, name string) (*domain.Tool, error) { + return r.ToolTable.SelectOne( + "name=$1 and team_id=$2 and team_id in (select team_id from user_teams where user_id=$3)", + name, + teamID, + userID, + ) +} + +func (r *TursoToolRepository) GetToolByID(userID string, teamID string, id string) (*domain.Tool, error) { + return r.ToolTable.SelectOne( + "id=$1 and team_id=$2 and team_id in (select team_id from user_teams where user_id=$3)", + id, + teamID, + userID, + ) +} + +func (r *TursoToolRepository) CreateTool(userID string, Tool *domain.Tool) (*domain.Tool, error) { + return r.ToolTable.Insert(Tool) +} + +func (r *TursoToolRepository) UpdateTool(userID string, Tool *domain.Tool) (*domain.Tool, error) { + return r.ToolTable.Update(Tool) +} + +func (r *TursoToolRepository) DeleteTool(userID string, teamID string, name string) error { + return r.ToolTable.DeleteWhere( + "name=$1 and team_id=$2 and team_id in (select team_id from user_teams where user_id=$3)", + name, + teamID, + userID, + ) +} diff --git a/backend/internal/adapter/tools/searxng/tool.go b/backend/internal/adapter/tools/searxng/tool.go new file mode 100644 index 0000000..dff5b32 --- /dev/null +++ b/backend/internal/adapter/tools/searxng/tool.go @@ -0,0 +1,151 @@ +package searxng + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + + "trankilou.fr/lassistanoque/backend/internal/domain" +) + +type SearxngConfig struct { + URL string `json:"url"` +} + +type SearxngTool struct { +} + +func (t *SearxngTool) Name() string { + return "searxng" +} + +func (t *SearxngTool) Description() string { + return "Search the web using a SearXNG instance. Returns a list of relevant results with title, URL, and snippet." +} + +func (t *SearxngTool) Params() []domain.ToolParam { + return []domain.ToolParam{ + {JsonName: "url", Type: "string", Mandatory: true, Description: "Searxng URL"}, + } +} + +func (t *SearxngTool) Definition(ctx context.Context) *domain.ToolDefinition { + return &domain.ToolDefinition{ + Type: "function", + Function: &domain.ToolFunction{ + Name: t.Name(), + Description: t.Description(), + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "The search query", + }, + "categories": map[string]any{ + "type": "string", + "description": "Comma-separated list of categories: general, news, images, science, it, social_media. Defaults to 'general'.", + }, + "language": map[string]any{ + "type": "string", + "description": "Language code for results, e.g. 'fr', 'en'. Defaults to 'all'.", + }, + }, + "required": []string{"query"}, + }, + }, + } +} + +type searxngResponse struct { + Results []searxngResult `json:"results"` +} + +type searxngResult struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Engine string `json:"engine"` +} + +func (t *SearxngTool) Execute(ctx context.Context, input []byte, configuration string) ([]byte, error) { + + slog.Debug("[searxng]", "input", string(input)) + + var params struct { + Query string `json:"query"` + Categories string `json:"categories"` + Language string `json:"language"` + } + if err := json.Unmarshal(input, ¶ms); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + + var toolCfg SearxngConfig + err := json.Unmarshal([]byte(configuration), &toolCfg) + if err != nil { + return nil, fmt.Errorf("searxng tool not configured: %w", err) + } + if toolCfg.URL == "" { + return nil, fmt.Errorf("searxng tool config missing 'url' field") + } + baseURL := toolCfg.URL + + categories := "general" + if params.Categories != "" { + categories = params.Categories + } + language := "all" + if params.Language != "" { + language = params.Language + } + + reqURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=%s&language=%s", + strings.TrimRight(baseURL, "/"), + url.QueryEscape(params.Query), + url.QueryEscape(categories), + url.QueryEscape(language), + ) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to build request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("searxng request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("searxng returned status %d", resp.StatusCode) + } + + var searchResp searxngResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if len(searchResp.Results) == 0 { + return []byte("No results found."), nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Search results for \"%s\":\n\n", params.Query) + for i, r := range searchResp.Results { + fmt.Fprintf(&sb, "%d. %s\n", i+1, r.Title) + fmt.Fprintf(&sb, " URL: %s\n", r.URL) + if r.Content != "" { + fmt.Fprintf(&sb, " %s\n", r.Content) + } + fmt.Fprintf(&sb, "\n") + } + + return []byte(sb.String()), nil +} diff --git a/backend/internal/adapter/tools/weather/tool.go b/backend/internal/adapter/tools/weather/tool.go index 33e0e21..544fcea 100644 --- a/backend/internal/adapter/tools/weather/tool.go +++ b/backend/internal/adapter/tools/weather/tool.go @@ -25,6 +25,10 @@ func (t *WeatherTool) Description() string { return "Get the current weather for a given location" } +func (t *WeatherTool) Params() []domain.ToolParam { + return []domain.ToolParam{} +} + func (t *WeatherTool) Definition(ctx context.Context) *domain.ToolDefinition { return &domain.ToolDefinition{ Type: "function", @@ -45,7 +49,7 @@ func (t *WeatherTool) Definition(ctx context.Context) *domain.ToolDefinition { } } -func (t *WeatherTool) Execute(ctx context.Context, input []byte) ([]byte, error) { +func (t *WeatherTool) Execute(ctx context.Context, input []byte, configuration string) ([]byte, error) { var params struct { Location string `json:"location"` diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3360cf2..319b017 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -29,6 +29,7 @@ type Config struct { StorageS3AccessKeySecret string // S3 StorageS3UseSSL string // S3 ServerID string + SearXngUrl string } var config *Config diff --git a/backend/internal/domain/llm.go b/backend/internal/domain/llm.go index 8ba76bd..ce1fa33 100644 --- a/backend/internal/domain/llm.go +++ b/backend/internal/domain/llm.go @@ -66,11 +66,19 @@ type ChatParams struct { OnChunk StreamCallback } -type Tool interface { +type ToolParam struct { + JsonName string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Mandatory bool `json:"mandatory"` +} + +type ITool interface { Name() string Description() string + Params() []ToolParam Definition(ctx context.Context) *ToolDefinition - Execute(ctx context.Context, input []byte) ([]byte, error) + Execute(ctx context.Context, input []byte, configuration string) ([]byte, error) } type ToolDefinition struct { diff --git a/backend/internal/domain/tool.go b/backend/internal/domain/tool.go new file mode 100644 index 0000000..ae041cf --- /dev/null +++ b/backend/internal/domain/tool.go @@ -0,0 +1,23 @@ +package domain + +import "time" + +type Tool struct { + ID string `db:"id" json:"id"` + TeamID string `db:"team_id" json:"teamId"` + Name string `db:"name" json:"name"` + Enabled bool `db:"enabled" json:"enabled"` + Configuration string `db:"configuration" json:"configuration"` + DateCreated time.Time `db:"_date_created" json:"_date_created"` + DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"` + VersionId string `db:"_version" json:"_version"` +} + +type ToolRepository interface { + ListTools(userID string, teamID string) ([]*Tool, error) + GetToolByName(userID string, teamID string, name string) (*Tool, error) + GetToolByID(userID string, teamID string, id string) (*Tool, error) + CreateTool(userID string, agent *Tool) (*Tool, error) + UpdateTool(userID string, agent *Tool) (*Tool, error) + DeleteTool(userID string, teamID string, name string) error +} diff --git a/backend/internal/brain/router.go b/backend/internal/gateway/router.go similarity index 98% rename from backend/internal/brain/router.go rename to backend/internal/gateway/router.go index 8c6b6cb..0630b5a 100644 --- a/backend/internal/brain/router.go +++ b/backend/internal/gateway/router.go @@ -1,4 +1,4 @@ -package brain +package gateway import ( "context" diff --git a/backend/internal/brain/session.go b/backend/internal/gateway/session.go similarity index 78% rename from backend/internal/brain/session.go rename to backend/internal/gateway/session.go index 93e025b..d3fb094 100644 --- a/backend/internal/brain/session.go +++ b/backend/internal/gateway/session.go @@ -1,4 +1,4 @@ -package brain +package gateway type Session struct { serverID string diff --git a/backend/internal/http/handlers/tool.go b/backend/internal/http/handlers/tool.go new file mode 100644 index 0000000..8f3e73d --- /dev/null +++ b/backend/internal/http/handlers/tool.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "net/http" + + "github.com/labstack/echo/v5" + "trankilou.fr/lassistanoque/backend/internal/domain" + "trankilou.fr/lassistanoque/backend/internal/service/auth" + "trankilou.fr/lassistanoque/backend/internal/service/tool" + "trankilou.fr/lassistanoque/backend/internal/service/user" +) + +func NewToolGroup( + prefix string, + e *echo.Group, + ToolService *tool.Service, + userService *user.Service, + middlewares ...echo.MiddlewareFunc) *echo.Group { + + ToolHandler := &ToolHandler{ + ToolService: ToolService, + userService: userService, + } + Tool := e.Group(prefix, middlewares...) + + Tool.GET("/:space", ToolHandler.ListTools) + Tool.GET("/:space/:tool", ToolHandler.GetTool) + Tool.GET("/:space/:tool/params", ToolHandler.GetToolParams) + Tool.PUT("/:space", ToolHandler.UpdateTool) + Tool.POST("/:space", ToolHandler.CreateTool) + //Tool.DELETE("/:space/:tool", ToolHandler.DeleteTool) + + return Tool +} + +type ToolHandler struct { + ToolService *tool.Service + userService *user.Service +} + +func (h *ToolHandler) ListTools(c *echo.Context) error { + userID := c.Get(auth.ContextUserIDKey).(string) + teamID := c.Param("space") + Tools, err := h.ToolService.ListTools(userID, teamID) + if err != nil { + c.Logger().Error("error listing Tools", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "error listing Tools") + } + return c.JSON(http.StatusOK, Tools) +} + +func (h *ToolHandler) GetTool(c *echo.Context) error { + userID := c.Get(auth.ContextUserIDKey).(string) + teamID := c.Param("space") + ToolID := c.Param("tool") + Tool, err := h.ToolService.GetTool(userID, teamID, ToolID) + if err != nil { + c.Logger().Error("error getting Tool", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "error getting Tool") + } + return c.JSON(http.StatusOK, Tool) +} + +func (h *ToolHandler) GetToolParams(c *echo.Context) error { + userID := c.Get(auth.ContextUserIDKey).(string) + teamID := c.Param("space") + toolID := c.Param("tool") + tool, err := h.ToolService.GetTool(userID, teamID, toolID) + if err != nil { + c.Logger().Error("error getting Tool", "error", err, "teamid", teamID, "toolID", toolID) + return echo.NewHTTPError(http.StatusBadRequest, "error getting Tool") + } + toolImpl := h.ToolService.GetToolImpl(tool.Name) + return c.JSON(http.StatusOK, toolImpl.Params()) +} + +func (h *ToolHandler) CreateTool(c *echo.Context) error { + userID := c.Get(auth.ContextUserIDKey).(string) + teamID := c.Param("space") + var Tool domain.Tool + err := c.Bind(&Tool) + if err != nil { + c.Logger().Error("error binding Tool", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "error binding Tool") + } + Tool.TeamID = teamID + updTool, err := h.ToolService.CreateTool(userID, &Tool) + if err != nil { + c.Logger().Error("error creating Tool", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "error creating Tool") + } + return c.JSON(http.StatusOK, updTool) +} + +func (h *ToolHandler) UpdateTool(c *echo.Context) error { + userID := c.Get(auth.ContextUserIDKey).(string) + teamID := c.Param("space") + var Tool domain.Tool + err := c.Bind(&Tool) + if err != nil { + c.Logger().Error("error binding Tool", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "error binding Tool") + } + Tool.TeamID = teamID + updTool, err := h.ToolService.UpdateTool(userID, &Tool) + if err != nil { + c.Logger().Error("error updating Tool", "error", err) + return echo.NewHTTPError(http.StatusBadRequest, "error updating Tool") + } + return c.JSON(http.StatusOK, updTool) +} + +// func (h *ToolHandler) DeleteTool(c *echo.Context) error { +// userID := c.Get(auth.ContextUserIDKey).(string) +// teamID := c.Param("space") +// ToolID := c.Param("tool") +// err := h.ToolService.DeleteTool(userID, teamID, ToolID) +// if err != nil { +// c.Logger().Error("error creating Tool", "error", err) +// return echo.NewHTTPError(http.StatusBadRequest, "error creating Tool") +// } +// return c.JSON(http.StatusOK, ToolID) +// } diff --git a/backend/internal/http/router.go b/backend/internal/http/router.go index 7264ab6..ff5c2ad 100644 --- a/backend/internal/http/router.go +++ b/backend/internal/http/router.go @@ -16,6 +16,7 @@ import ( "trankilou.fr/lassistanoque/backend/internal/service/chat" "trankilou.fr/lassistanoque/backend/internal/service/provider" "trankilou.fr/lassistanoque/backend/internal/service/storage" + "trankilou.fr/lassistanoque/backend/internal/service/tool" "trankilou.fr/lassistanoque/backend/internal/service/user" ) @@ -42,6 +43,7 @@ type Dependencies struct { ProviderService *provider.Service AgentService *agent.Service ChatService *chat.Service + ToolService *tool.Service TokenManager auth.TokenManager } @@ -82,6 +84,7 @@ func NewRouter(deps Dependencies) *Router { _ = handlers.NewMiscGroup("/misc", api, deps.TokenManager.TokenMiddleware) _ = handlers.NewModelGroup("/provider", api, deps.ProviderService, deps.TokenManager.TokenMiddleware) _ = handlers.NewAgentGroup("/agent", api, deps.AgentService, deps.UserService, deps.TokenManager.TokenMiddleware) + _ = handlers.NewToolGroup("/tool", api, deps.ToolService, deps.UserService, deps.TokenManager.TokenMiddleware) _ = handlers.NewTeamGroup("/team", api, deps.UserService, deps.TokenManager.TokenMiddleware) _ = handlers.NewChatGroup("/chat", api, deps.ChatService, deps.TokenManager.TokenMiddleware) diff --git a/backend/internal/service/chat/service.go b/backend/internal/service/chat/service.go index 0b8ed5e..1499047 100644 --- a/backend/internal/service/chat/service.go +++ b/backend/internal/service/chat/service.go @@ -8,6 +8,7 @@ import ( "time" "trankilou.fr/lassistanoque/backend/internal/domain" + "trankilou.fr/lassistanoque/backend/internal/service/tool" ) type Service struct { @@ -17,6 +18,7 @@ type Service struct { repoChat domain.ChatRepository llmEngine domain.LLMEngine runningSessions map[string]*ChatSession + toolService *tool.Service mu sync.Mutex } @@ -26,6 +28,7 @@ func NewService( repoProvider domain.ProviderRepository, repoChat domain.ChatRepository, llmEngine domain.LLMEngine, + toolService *tool.Service, ) *Service { return &Service{ repoUser: repoUser, @@ -33,6 +36,7 @@ func NewService( repoProvider: repoProvider, repoChat: repoChat, llmEngine: llmEngine, + toolService: toolService, runningSessions: make(map[string]*ChatSession), } } @@ -194,6 +198,7 @@ func (s *Service) runQuery( modelID: modelID, messages: messages, llmEngine: s.llmEngine, + toolService: s.toolService, subscribers: make([]domain.StreamCallback, 0), repoChat: s.repoChat, } diff --git a/backend/internal/service/chat/session.go b/backend/internal/service/chat/session.go index c44569f..a2d1671 100644 --- a/backend/internal/service/chat/session.go +++ b/backend/internal/service/chat/session.go @@ -3,9 +3,11 @@ package chat import ( "context" _ "embed" + "log/slog" "sync" "trankilou.fr/lassistanoque/backend/internal/domain" + "trankilou.fr/lassistanoque/backend/internal/service/tool" ) //go:embed system_prompt.md @@ -20,6 +22,7 @@ type ChatSession struct { modelID string messages []*domain.Message llmEngine domain.LLMEngine + toolService *tool.Service repoChat domain.ChatRepository subscribers []domain.StreamCallback mu sync.Mutex @@ -30,7 +33,7 @@ func (s *ChatSession) run(ctx context.Context) error { params := &domain.LLMParams{} tools := make([]*domain.ToolDefinition, 0) - for _, t := range allTools { + for _, t := range s.toolService.GetAllToolImpl() { tools = append(tools, t.Definition(ctx)) } params.Tools = tools @@ -76,8 +79,21 @@ func (s *ChatSession) run(ctx context.Context) error { continue_loop = true for _, tc := range msg.ToolCalls { - tool := GetTool(tc.Function.Name) - output, err := tool.Execute(ctx, []byte(tc.Function.Arguments)) + toolImpl := s.toolService.GetToolImpl(tc.Function.Name) + slog.Info("Execute tool", "name", tc.Function.Name, "impl", toolImpl) + toolRecord, err := s.toolService.GetToolByName(s.userID, s.teamID, toolImpl.Name()) + if err != nil { + slog.Info("Execute tool", "error", err) + toolResponse := &domain.Message{ + ChatID: s.chatID, + TeamID: s.teamID, + ToolCallID: tc.ID, + Role: string(domain.RoleTool), + Content: "ERROR: " + err.Error(), + } + s.repoChat.CreateChatMessage(s.userID, toolResponse) + } + output, err := toolImpl.Execute(ctx, []byte(tc.Function.Arguments), toolRecord.Configuration) var toolResponse *domain.Message if err != nil { toolResponse = &domain.Message{ diff --git a/backend/internal/service/chat/tools.go b/backend/internal/service/chat/tools.go deleted file mode 100644 index 2924385..0000000 --- a/backend/internal/service/chat/tools.go +++ /dev/null @@ -1,14 +0,0 @@ -package chat - -import "trankilou.fr/lassistanoque/backend/internal/domain" - -var allTools = make(map[string]domain.Tool) - -func RegisterTool(tool domain.Tool) error { - allTools[tool.Name()] = tool - return nil -} - -func GetTool(name string) domain.Tool { - return allTools[name] -} diff --git a/backend/internal/service/tool/service.go b/backend/internal/service/tool/service.go new file mode 100644 index 0000000..1aff9a1 --- /dev/null +++ b/backend/internal/service/tool/service.go @@ -0,0 +1,100 @@ +package tool + +import ( + "fmt" + "log/slog" + + "trankilou.fr/lassistanoque/backend/internal/domain" +) + +type Service struct { + repo domain.ToolRepository + repoUser domain.UserRepository + allTools map[string]domain.ITool +} + +func NewService( + repo domain.ToolRepository, + repoUser domain.UserRepository, +) *Service { + return &Service{ + repo: repo, + repoUser: repoUser, + allTools: make(map[string]domain.ITool), + } +} + +func (s *Service) ListTools(userID string, teamID string) ([]*domain.Tool, error) { + + for _, tool := range s.allTools { + _, err := s.repo.GetToolByName(userID, teamID, tool.Name()) + slog.Info("ListTools", "err1", err) + if err != nil { + _, err = s.repo.CreateTool(userID, &domain.Tool{ + TeamID: teamID, + Name: tool.Name(), + Enabled: true, + }) + if err != nil { + slog.Info("ListTools", "err2", err) + } + } + } + + return s.repo.ListTools(userID, teamID) +} + +func (s *Service) GetTool(userID string, teamID string, id string) (*domain.Tool, error) { + return s.repo.GetToolByID(userID, teamID, id) +} + +func (s *Service) GetToolByName(userID string, teamID string, name string) (*domain.Tool, error) { + return s.repo.GetToolByName(userID, teamID, name) +} + +func (s *Service) CreateTool(userID string, Tool *domain.Tool) (*domain.Tool, error) { + + if _, err := s.repoUser.FindUserTeam(userID, Tool.TeamID); err != nil { + return nil, fmt.Errorf("error finding user in team: %s", err) + } + + return s.repo.CreateTool(userID, Tool) +} + +func (s *Service) UpdateTool(userID string, Tool *domain.Tool) (*domain.Tool, error) { + if _, err := s.repoUser.FindUserTeam(userID, Tool.TeamID); err != nil { + return nil, fmt.Errorf("error finding user in team: %s", err) + } + + if _, err := s.repo.GetToolByID(userID, Tool.TeamID, Tool.ID); err != nil { + return nil, fmt.Errorf("error finding Tool: %s", err) + } + + return s.repo.UpdateTool(userID, Tool) +} + +func (s *Service) DeleteTool(userID string, teamID string, id string) error { + if _, err := s.repoUser.FindUserTeam(userID, teamID); err != nil { + return fmt.Errorf("error finding user in team: %s", err) + } + + if _, err := s.repo.GetToolByID(userID, teamID, id); err != nil { + return fmt.Errorf("error finding Tool: %s", err) + } + + return s.repo.DeleteTool(userID, teamID, id) + +} + +func (s *Service) RegisterToolImpl(tool domain.ITool) error { + s.allTools[tool.Name()] = tool + return nil +} + +func (s *Service) GetAllToolImpl() map[string]domain.ITool { + return s.allTools +} + +func (s *Service) GetToolImpl(name string) domain.ITool { + return s.allTools[name] +} diff --git a/backend/lassistanoque.db-wal b/backend/lassistanoque.db-wal index b11e179..bbca6b4 100644 Binary files a/backend/lassistanoque.db-wal and b/backend/lassistanoque.db-wal differ diff --git a/backend/web/src/lib/api/endpoints/tool.ts b/backend/web/src/lib/api/endpoints/tool.ts new file mode 100644 index 0000000..a577478 --- /dev/null +++ b/backend/web/src/lib/api/endpoints/tool.ts @@ -0,0 +1,10 @@ +import { api } from '../client'; +import type { Tool, ToolParam } from "$lib/types/api" + +export const toolApi = { + getTools: (space: string) => api.get(`/tool/${space}`), + getTool: (space: string, id: string) => api.get(`/tool/${space}/${id}`), + getToolParams: (space: string, id: string) => api.get(`/tool/${space}/${id}/params`), + createTool: (space: string,data: Partial) => api.post(`/tool/${space}`, data), + updateTool: (space: string,data: Partial) => api.put(`/tool/${space}`, data), +}; diff --git a/backend/web/src/lib/types/api.ts b/backend/web/src/lib/types/api.ts index b5fe81b..bc3fc19 100644 --- a/backend/web/src/lib/types/api.ts +++ b/backend/web/src/lib/types/api.ts @@ -104,6 +104,24 @@ export interface Message { _version: string } +export interface Tool { + id: string + teamId: string + name: string + configuration: string + enabled: boolean + _date_created: string + _date_updated: string + _version: string +} + +export interface ToolParam { + name: string + type: string + description: string + mandatory: boolean +} + export interface ToolCall { ID: string Type: string diff --git a/backend/web/src/routes/(authenticated)/[space]/settings/+page.svelte b/backend/web/src/routes/(authenticated)/[space]/settings/+page.svelte index e9fdc35..702f3b8 100644 --- a/backend/web/src/routes/(authenticated)/[space]/settings/+page.svelte +++ b/backend/web/src/routes/(authenticated)/[space]/settings/+page.svelte @@ -17,9 +17,10 @@ href="settings/tools" > Outils - 12 + + diff --git a/backend/web/src/routes/(authenticated)/[space]/settings/agents/+page.svelte b/backend/web/src/routes/(authenticated)/[space]/settings/agents/+page.svelte index 92086ca..b8c4639 100644 --- a/backend/web/src/routes/(authenticated)/[space]/settings/agents/+page.svelte +++ b/backend/web/src/routes/(authenticated)/[space]/settings/agents/+page.svelte @@ -108,7 +108,6 @@ } const handleKeydown = (event: KeyboardEvent) => { - event.preventDefault() if (event.key==="Escape") { handleCancelEditAgent() } @@ -149,7 +148,7 @@ /> {/key} {:else} -
+
{ agent.name }
diff --git a/backend/web/src/routes/(authenticated)/[space]/settings/models/+page.svelte b/backend/web/src/routes/(authenticated)/[space]/settings/models/+page.svelte index 7758baf..fc71d21 100644 --- a/backend/web/src/routes/(authenticated)/[space]/settings/models/+page.svelte +++ b/backend/web/src/routes/(authenticated)/[space]/settings/models/+page.svelte @@ -102,7 +102,6 @@ } const handleKeydown = (event: KeyboardEvent) => { - event.preventDefault() if (event.key==="Escape") { cancelEditProviderHandler() } @@ -146,7 +145,7 @@ /> {/key} {:else} -
+
{ p.name } ({ p.type }) diff --git a/backend/web/src/routes/(authenticated)/[space]/settings/tools/+page.svelte b/backend/web/src/routes/(authenticated)/[space]/settings/tools/+page.svelte index b746249..6463593 100644 --- a/backend/web/src/routes/(authenticated)/[space]/settings/tools/+page.svelte +++ b/backend/web/src/routes/(authenticated)/[space]/settings/tools/+page.svelte @@ -1 +1,96 @@ -
Modèles
+ + + + +
+ + {#each tools as t (t.id) } + {#if editing===t.id} + {#key t.id} + + {/key} + {:else} +
+
+ { t.name } + ({ t.enabled }) +
+ + +
+ {/if} + {/each} + +
diff --git a/backend/web/src/routes/(authenticated)/[space]/settings/tools/+page.ts b/backend/web/src/routes/(authenticated)/[space]/settings/tools/+page.ts index 7d0a6e3..0a6e4ed 100644 --- a/backend/web/src/routes/(authenticated)/[space]/settings/tools/+page.ts +++ b/backend/web/src/routes/(authenticated)/[space]/settings/tools/+page.ts @@ -1,6 +1,11 @@ -export function load() { +import { toolApi } from "$lib/api/endpoints/tool" +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { return { title: 'Paramétrage : Outils', - back: "/settings", + back: "../settings", + space: params.space, + tools: await toolApi.getTools(params.space), }; } diff --git a/backend/web/src/routes/(authenticated)/[space]/settings/tools/ToolForm.svelte b/backend/web/src/routes/(authenticated)/[space]/settings/tools/ToolForm.svelte new file mode 100644 index 0000000..f2d56a5 --- /dev/null +++ b/backend/web/src/routes/(authenticated)/[space]/settings/tools/ToolForm.svelte @@ -0,0 +1,100 @@ + + +
+
+
{errors.clear()}}> +
+ + + +
+ +
+ + + +
+ + + {#each toolParams as param (param.name)} + +
+ + paramValues.get(param.name) ?? '', (v) => paramValues.set(param.name, v)} + class="textinput" + placeholder={param.description} + /> + +
+ + {/each} + + +
+ + +
+
+
+
diff --git a/backend/web/src/routes/layout.css b/backend/web/src/routes/layout.css index 7019523..7cadec8 100644 --- a/backend/web/src/routes/layout.css +++ b/backend/web/src/routes/layout.css @@ -137,6 +137,10 @@ h2 { @apply h-8 w-full rounded border-stone-300 bg-stone-50 px-2 py-1 text-sm text-stone-900 dark:border-stone-700 dark:bg-stone-950 dark:text-stone-100; } +.checkboxinput { + @apply rounded border border-stone-500 bg-stone-100 text-sm text-stone-900 dark:bg-stone-900 dark:text-stone-100; +} + .areainput { @apply w-full rounded border-stone-300 bg-stone-50 px-2 py-1 text-sm text-stone-900 dark:bg-stone-950 dark:text-stone-100; } @@ -275,8 +279,6 @@ h2 { /* chat */ .message { - /*@apply rounded p-2;*/ - &.user { @apply bg-sky-500/20 px-2 py-1 text-sky-700 dark:text-sky-300; } @@ -318,7 +320,7 @@ h2 { } pre { - @apply my-2 rounded p-1 whitespace-pre dark:bg-stone-700; + @apply my-2 rounded px-2 py-1 text-xs dark:bg-stone-700; } p { @@ -328,6 +330,7 @@ h2 { .message-content { @apply text-sm; + @apply whitespace-pre-line; &.reasoning { @apply text-white/50;