Ajout de SEARXNG pour les recherches web

This commit is contained in:
fabien committed 2026-10-05 19:05:24 +02:00
1 parent 87eeb41364
commit 345129c7bf
30 files changed
+919 -166

No files matched your search

-105
View File
@@ -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()
}
+145
View File
@@ -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
}
+11 -26
View File
@@ -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")
@@ -15,6 +15,7 @@ type Database interface {
ProviderRepository() domain.ProviderRepository
AgentRepository() domain.AgentRepository
ChatRepository() domain.ChatRepository
ToolRepository() domain.ToolRepository
Migrate() error
Close()
}
@@ -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)
}
@@ -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
@@ -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,
)
}
@@ -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, &params); 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
}
@@ -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"`
+1
View File
@@ -29,6 +29,7 @@ type Config struct {
StorageS3AccessKeySecret string // S3
StorageS3UseSSL string // S3
ServerID string
SearXngUrl string
}
var config *Config
+10 -2
View File
@@ -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 {
+23
View File
@@ -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
}
@@ -1,4 +1,4 @@
package brain
package gateway
import (
"context"
@@ -1,4 +1,4 @@
package brain
package gateway
type Session struct {
serverID string
+123
View File
@@ -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)
// }
+3
View File
@@ -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)
+5
View File
@@ -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,
}
+19 -3
View File
@@ -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{
-14
View File
@@ -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]
}
+100
View File
@@ -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]
}
Binary file not shown.
+10
View File
@@ -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[]>(`/tool/${space}`),
getTool: (space: string, id: string) => api.get<Tool>(`/tool/${space}/${id}`),
getToolParams: (space: string, id: string) => api.get<ToolParam[]>(`/tool/${space}/${id}/params`),
createTool: (space: string,data: Partial<Tool>) => api.post<Tool>(`/tool/${space}`, data),
updateTool: (space: string,data: Partial<Tool>) => api.put<Tool>(`/tool/${space}`, data),
};
+18
View File
@@ -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
@@ -17,9 +17,10 @@
href="settings/tools"
>
Outils
<span class="ml-2 text-xs bg-red-900 text-red-300 rounded-lg p-1">12</span>
<!-- <span class="ml-2 text-xs bg-red-900 text-red-300 rounded-lg p-1">12</span>-->
</a>
<!--
<a class="bt p-5 text-center"
title="Skills"
href="settings/skills">
@@ -58,4 +59,6 @@
Utilisateurs
<span class="ml-2 text-xs bg-red-900 text-red-300 rounded-lg p-1">1</span>
</a>
-->
</div>
@@ -108,7 +108,6 @@
}
const handleKeydown = (event: KeyboardEvent) => {
event.preventDefault()
if (event.key==="Escape") {
handleCancelEditAgent()
}
@@ -149,7 +148,7 @@
/>
{/key}
{:else}
<div class="rounded border p-2 my-2 border-stone-500 flex">
<div class="rounded border p-2 my-2 border-stone-500 flex formgroup">
<div class="flex-1 p-2">
<span>{ agent.name }</span>
</div>
@@ -102,7 +102,6 @@
}
const handleKeydown = (event: KeyboardEvent) => {
event.preventDefault()
if (event.key==="Escape") {
cancelEditProviderHandler()
}
@@ -146,7 +145,7 @@
/>
{/key}
{:else}
<div class="rounded border p-2 my-2 border-stone-500 flex">
<div class="rounded border p-2 my-2 border-stone-500 flex formgroup">
<div class="flex-1 p-2">
<span>{ p.name }</span>
<span class="text-stone-500">({ p.type })</span>
@@ -1 +1,96 @@
<div>Modèles</div>
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { untrack } from 'svelte';
import type { Tool } from '$lib/types/api'
import type { PageProps } from './$types';
import { toolApi } from "$lib/api/endpoints/tool"
//import { validateUrl } from '$lib/utilities/validators'
import ToolForm from './ToolForm.svelte'
let { data }: PageProps = $props();
let tools = $state(untrack(()=>data.tools))
let adding = $state(false)
let editing = $state(null as string|null)
let errors = new SvelteMap<string,string>()
const cancelEditHandler = () => {
editing = null
}
const submitEdit = (tool: Tool) => {
if (errors.size === 0) {
toolApi.updateTool(data.space,tool)
.then((t)=>{
tools = tools.map((it)=>{return t.id===it.id ? t : it})
editing = null
})
.catch((err)=>{
console.log(err)
})
}
}
const handleEdit = async (id: string) => {
errors.clear()
if (adding) return
toolApi.getTool(data.space,id)
.then((data)=>{
tools = tools.map((it)=>it.id===id ? data : it)
editing = id
})
}
// const handleDelete = (id: string) => {
// toolApi.deleteTool(data.space,id)
// .then(() => {
// tools = tools.filter((it)=>it.id!==id)
// })
// .catch((err) => {console.log(err)})
// }
const handleKeydown = (event: KeyboardEvent) => {
if (event.key==="Escape") {
cancelEditHandler()
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="container">
{#each tools as t (t.id) }
{#if editing===t.id}
{#key t.id}
<ToolForm
item={t}
errors={errors}
onSave={submitEdit}
onCancel={cancelEditHandler}
/>
{/key}
{:else}
<div class="rounded border p-2 my-2 border-stone-500 flex formgroup">
<div class="flex-1 p-2">
<span>{ t.name }</span>
<span class="text-stone-500">({ t.enabled })</span>
</div>
<button
class="bt small mx-1"
onclick={()=>handleEdit(t.id)}
aria-label="Modifier"
>
<i class="icon-edit"></i>
</button>
</div>
{/if}
{/each}
</div>
@@ -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),
};
}
@@ -0,0 +1,100 @@
<script lang="ts">
import FormError from '$lib/components/FormError.svelte'
import { SvelteMap } from 'svelte/reactivity';
import { toolApi } from "$lib/api/endpoints/tool"
import type { Tool, ToolParam } from "$lib/types/api"
import { onMount, untrack } from 'svelte';
type Props = {
item: Tool
errors: Map<string,string>
onSave: (item: Tool) => void
onCancel: () => void
}
let {
item,
errors,
onSave,
onCancel,
}: Props = $props();
let tool = $state(untrack(()=>item))
let toolParams = $state([] as ToolParam[])
let paramValues = new SvelteMap<string, string>();
onMount(async () => {
toolParams = await toolApi.getToolParams(item.teamId, item.id)
paramValues = new SvelteMap<string, string>(Object.entries(JSON.parse(item.configuration)));
})
const autofocus = (node: HTMLElement) => {
node.focus();
}
const handleSave = (e: Event) => {
e.preventDefault()
item.configuration = JSON.stringify(Object.fromEntries(paramValues));
onSave(item)
}
const handleCancel = (e: Event) => {
e.preventDefault()
onCancel()
}
</script>
<div>
<div class="formgroup my-2">
<form onsubmit={handleSave} onchange={()=>{errors.clear()}}>
<div class="formelement">
<label for="name">Nom</label>
<input
id="name"
type="text"
bind:value={tool.name}
class="textinput"
placeholder="nom affiché"
disabled
use:autofocus
/>
<FormError key="name" errors={errors} />
</div>
<div class="formelement">
<label for="enabled">Actif</label>
<input
id="enabled"
type="checkbox"
bind:checked={tool.enabled}
class="checkboxinput"
/>
<FormError key="baseUrl" errors={errors} />
</div>
{#each toolParams as param (param.name)}
<div class="formelement">
<label for={param.name}>{param.name}</label>
<input
id={param.name}
bind:value={() => paramValues.get(param.name) ?? '', (v) => paramValues.set(param.name, v)}
class="textinput"
placeholder={param.description}
/>
<FormError key="baseUrl" errors={errors} />
</div>
{/each}
<div class="text-right">
<button type="button" class="bt" onclick={handleCancel}>Annuler</button>
<button type="submit" class="bt mx-4">Enregistrer</button>
</div>
</form>
</div>
</div>
+6 -3
View File
@@ -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;