diff --git a/backend/cmd/ioc.go b/backend/cmd/ioc.go index 8e9db7e..35e99db 100644 --- a/backend/cmd/ioc.go +++ b/backend/cmd/ioc.go @@ -8,6 +8,7 @@ import ( "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/browser" "trankilou.fr/lassistanoque/backend/internal/adapter/tools/searxng" "trankilou.fr/lassistanoque/backend/internal/adapter/tools/weather" "trankilou.fr/lassistanoque/backend/internal/domain" @@ -140,6 +141,7 @@ func GetToolService() *tool.Service { toolService = tool.NewService(db.ToolRepository(), db.UserRepository()) toolService.RegisterToolImpl(&weather.WeatherTool{}) toolService.RegisterToolImpl(&searxng.SearxngTool{}) + toolService.RegisterToolImpl(&browser.BrowserTool{}) } return toolService } diff --git a/backend/internal/adapter/database/turso/chat_repository.go b/backend/internal/adapter/database/turso/chat_repository.go index 6f2e109..5822905 100644 --- a/backend/internal/adapter/database/turso/chat_repository.go +++ b/backend/internal/adapter/database/turso/chat_repository.go @@ -41,6 +41,15 @@ func (r *TursoChatRepository) ListChats(userID string, teamID string, page int) ) } +func (r *TursoChatRepository) ListChatsWithUnfreshTitles(teamID string) ([]*domain.Chat, error) { + return r.chatTable.Select( + orm.WithWhere( + "team_id=$1 and fresh_title=0", + teamID, + ), + ) +} + func (r *TursoChatRepository) GetChat(userID string, teamID string, id string) (*domain.Chat, error) { return r.chatTable.SelectOne( "id=$1 and team_id=$2 and team_id in (select team_id from user_teams where user_id=$3)", diff --git a/backend/internal/adapter/database/turso/db.go b/backend/internal/adapter/database/turso/db.go index 5b46ece..d247f27 100644 --- a/backend/internal/adapter/database/turso/db.go +++ b/backend/internal/adapter/database/turso/db.go @@ -24,7 +24,7 @@ type TursoDB struct { func NewTursoDB(cfg *config.Config) (*TursoDB, error) { db, err := sql.Open("turso", cfg.DatabaseURL) if err != nil { - return nil, fmt.Errorf("error connecting to db %: %s", cfg.DatabaseURL, err) + return nil, fmt.Errorf("error connecting to db %s: %s", cfg.DatabaseURL, err) } return &TursoDB{ 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 703c0ec..aedcf89 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 @@ -125,6 +125,7 @@ create table chats ( user_id text, task_id text, title text, + fresh_title numeric default 0, _date_created numeric not null default current_timestamp, _date_updated numeric, _version text not null diff --git a/backend/internal/adapter/llm/anyllm.go b/backend/internal/adapter/llm/anyllm.go index 864e03a..5668427 100644 --- a/backend/internal/adapter/llm/anyllm.go +++ b/backend/internal/adapter/llm/anyllm.go @@ -203,6 +203,40 @@ func (e *AnyLLMEngine) Stream( } } +func (e *AnyLLMEngine) Generate( + ctx context.Context, + provider *domain.Provider, + modelID string, + messages []*domain.Message, +) (string, error) { + + p, err := providerFactory(provider) + if err != nil { + return "", err + } + + anyllmMessages := make([]anyllm.Message, 0) + for _, m := range messages { + anyllmMessages = append(anyllmMessages, anyllm.Message{ + Role: m.Role, + Content: m.Content, + }) + } + + response, err := p.Completion(ctx, anyllm.CompletionParams{ + Model: modelID, + Messages: anyllmMessages, + Stream: false, + }) + + if err != nil { + return "", err + } + + return response.Choices[0].Message.Content.(string), nil + +} + func toolConvert(domainTool *domain.ToolDefinition) providers.Tool { return providers.Tool{ Type: domainTool.Type, diff --git a/backend/internal/adapter/tools/browser/tool.go b/backend/internal/adapter/tools/browser/tool.go new file mode 100644 index 0000000..18095d6 --- /dev/null +++ b/backend/internal/adapter/tools/browser/tool.go @@ -0,0 +1,314 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/proto" + "github.com/go-rod/stealth" + "trankilou.fr/lassistanoque/backend/internal/domain" +) + +type BrowserConfig struct { + // Headless est un pointeur pour distinguer "non renseigné" (défaut : true) de "explicitement false". + Headless *bool `yaml:"headless"` + TimeoutSeconds int `yaml:"timeoutSeconds"` +} + +// BrowserTool maintains a persistent browser session across LLM tool calls. +type BrowserTool struct{} + +var ( + browserInstance *rod.Browser + currentPage *rod.Page + browserMu sync.Mutex + launchedHeadless bool +) + +func (t *BrowserTool) Name() string { + return "browser" +} + +func (t *BrowserTool) Description() string { + return "Control a web browser to navigate pages and interact with content. " + + "Maintains session state across calls (cookies, current page). " + + "Actions: 'navigate' (go to URL), 'click' (click element by CSS selector), " + + "'fill' (type into an input field), 'content' (get current page text), " + + "'links' (list all links on current page)." +} + +func (t *BrowserTool) Params() []domain.ToolParam { + return []domain.ToolParam{ + {JsonName: "headless", Type: "bool", Mandatory: true, Description: "Headless"}, + {JsonName: "timeoutSeconds", Type: "int", Mandatory: true, Description: "Timeout en secondes"}, + } +} + +func (t *BrowserTool) 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{ + "action": map[string]any{ + "type": "string", + "enum": []string{"navigate", "click", "fill", "content", "links"}, + "description": "The browser action to perform.", + }, + "url": map[string]any{ + "type": "string", + "description": "URL to navigate to. Required for action='navigate'.", + }, + "selector": map[string]any{ + "type": "string", + "description": "CSS selector of the element to interact with. Required for 'click' and 'fill'.", + }, + "value": map[string]any{ + "type": "string", + "description": "Text value to type into the field. Required for action='fill'.", + }, + }, + "required": []string{"action"}, + }, + }, + } +} + +func (t *BrowserTool) Execute(_ context.Context, input []byte, configuration string) ([]byte, error) { + + slog.Debug("[browser]", "input", string(input)) + + var params struct { + Action string `json:"action"` + URL string `json:"url"` + Selector string `json:"selector"` + Value string `json:"value"` + } + if err := json.Unmarshal(input, ¶ms); err != nil { + return nil, fmt.Errorf("invalid parameters: %w", err) + } + + var toolConfig BrowserConfig + err := json.Unmarshal([]byte(configuration), &toolConfig) + if err != nil { + return nil, err + } + timeout := time.Duration(toolConfig.TimeoutSeconds) * time.Second + + // Headless est nil quand la configuration ne le précise pas : défaut à true. + headless := true + if toolConfig.Headless != nil { + headless = *toolConfig.Headless + } + + if timeout <= 0 { + timeout = 60 * time.Second + } + + browserMu.Lock() + defer browserMu.Unlock() + + if err := ensureBrowser(headless); err != nil { + return nil, fmt.Errorf("browser init failed: %w", err) + } + + switch params.Action { + case "navigate": + if params.URL == "" { + return nil, fmt.Errorf("'url' is required for action 'navigate'") + } + return t.navigate(params.URL, timeout) + + case "click": + if params.Selector == "" { + return nil, fmt.Errorf("'selector' is required for action 'click'") + } + return t.click(params.Selector, timeout) + + case "fill": + if params.Selector == "" || params.Value == "" { + return nil, fmt.Errorf("'selector' and 'value' are required for action 'fill'") + } + return t.fill(params.Selector, params.Value, timeout) + + case "content": + return t.pageContent(timeout) + + case "links": + return t.pageLinks(timeout) + + default: + return nil, fmt.Errorf("unknown action %q", params.Action) + } +} + +func browserProfileDir() string { + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "lassistanoque", "browser-profile") + } + return filepath.Join(os.TempDir(), "lassistanoque-browser-profile") +} + +// ensureBrowser lance le navigateur au premier appel et le relance si le mode +// headless a changé dans la configuration. +func ensureBrowser(headless bool) error { + if browserInstance != nil { + if headless == launchedHeadless { + return nil + } + _ = browserInstance.Close() + browserInstance = nil + currentPage = nil + } + if err := initBrowser(headless); err != nil { + return err + } + launchedHeadless = headless + return nil +} + +func initBrowser(headless bool) error { + path, _ := launcher.LookPath() + controlURL, err := launcher.New(). + Bin(path). + Headless(headless). + UserDataDir(browserProfileDir()). + Delete("enable-automation"). + Launch() + if err != nil { + return fmt.Errorf("failed to launch browser: %w", err) + } + b := rod.New().ControlURL(controlURL) + if err := b.Connect(); err != nil { + return fmt.Errorf("failed to connect to browser: %w", err) + } + browserInstance = b + return nil +} + +func ensurePage(timeout time.Duration) error { + if currentPage == nil { + page, err := stealth.Page(browserInstance.Timeout(timeout)) + if err != nil { + return fmt.Errorf("failed to open page: %w", err) + } + currentPage = page + } + return nil +} + +func (t *BrowserTool) navigate(rawURL string, timeout time.Duration) ([]byte, error) { + if err := ensurePage(timeout); err != nil { + return nil, err + } + if err := currentPage.Timeout(timeout).Navigate(rawURL); err != nil { + return nil, fmt.Errorf("navigation failed: %w", err) + } + if err := currentPage.Timeout(timeout).WaitLoad(); err != nil { + return nil, fmt.Errorf("page load failed: %w", err) + } + return t.pageContent(timeout) +} + +func (t *BrowserTool) click(selector string, timeout time.Duration) ([]byte, error) { + if err := ensurePage(timeout); err != nil { + return nil, err + } + el, err := currentPage.Timeout(timeout).Element(selector) + if err != nil { + return nil, fmt.Errorf("element %q not found: %w", selector, err) + } + if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil { + return nil, fmt.Errorf("click failed: %w", err) + } + if err := currentPage.WaitIdle(timeout); err != nil { + return nil, fmt.Errorf("wait after click failed: %w", err) + } + return t.pageContent(timeout) +} + +func (t *BrowserTool) fill(selector, value string, timeout time.Duration) ([]byte, error) { + if err := ensurePage(timeout); err != nil { + return nil, err + } + el, err := currentPage.Timeout(timeout).Element(selector) + if err != nil { + return nil, fmt.Errorf("element %q not found: %w", selector, err) + } + if err := el.SelectAllText(); err != nil { + return nil, fmt.Errorf("failed to select text: %w", err) + } + if err := el.Input(value); err != nil { + return nil, fmt.Errorf("fill failed: %w", err) + } + return t.pageContent(timeout) +} + +func (t *BrowserTool) pageContent(timeout time.Duration) ([]byte, error) { + if currentPage == nil { + return []byte("No page loaded."), nil + } + + info, err := currentPage.Info() + if err != nil { + return nil, fmt.Errorf("failed to get page info: %w", err) + } + + titleRes, err := currentPage.Eval("() => document.title") + if err != nil { + return nil, fmt.Errorf("failed to get page title: %w", err) + } + title := titleRes.Value.String() + + body, err := currentPage.Timeout(timeout).Element("body") + if err != nil { + return nil, fmt.Errorf("failed to get page body: %w", err) + } + text, err := body.Text() + if err != nil { + return nil, fmt.Errorf("failed to extract page text: %w", err) + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Page: %s\nURL: %s\n\n%s", title, info.URL, text) + return []byte(sb.String()), nil +} + +func (t *BrowserTool) pageLinks(timeout time.Duration) ([]byte, error) { + if currentPage == nil { + return []byte("No page loaded."), nil + } + + elements, err := currentPage.Timeout(timeout).Elements("a[href]") + if err != nil { + return nil, fmt.Errorf("failed to get links: %w", err) + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Links on current page (%d):\n\n", len(elements)) + for i, el := range elements { + href, _ := el.Attribute("href") + text, _ := el.Text() + if href == nil { + continue + } + label := strings.TrimSpace(text) + if label == "" { + label = "(no text)" + } + fmt.Fprintf(&sb, "%d. %s — %s\n", i+1, label, *href) + } + return []byte(sb.String()), nil +} diff --git a/backend/internal/domain/llm.go b/backend/internal/domain/llm.go index ce1fa33..2aa818c 100644 --- a/backend/internal/domain/llm.go +++ b/backend/internal/domain/llm.go @@ -15,6 +15,7 @@ type Chat struct { UserID string `db:"user_id" json:"userId"` TaskID string `db:"task_id" json:"taskId,omitempty"` Title string `db:"title" json:"title"` + FreshTitle bool `db:"fresh_title" json:"freshTitle"` DateCreated time.Time `db:"_date_created" json:"_date_created"` DateUpdated *time.Time `db:"_date_updated" json:"_date_updated"` VersionId string `db:"_version" json:"_version"` @@ -139,10 +140,17 @@ type LLMEngine interface { params *LLMParams, messages []*Message, ) *Message + Generate( + ctx context.Context, + provider *Provider, + modelID string, + messages []*Message, + ) (string, error) } type ChatRepository interface { ListChats(userID string, teamID string, page int) ([]*Chat, error) + ListChatsWithUnfreshTitles(teamID string) ([]*Chat, error) GetChat(userID string, teamID string, id string) (*Chat, error) CreateChat(userID string, chat *Chat) (*Chat, error) UpdateChat(userID string, chat *Chat) (*Chat, error) diff --git a/backend/internal/http/handlers/chat.go b/backend/internal/http/handlers/chat.go index d35aff7..6896427 100644 --- a/backend/internal/http/handlers/chat.go +++ b/backend/internal/http/handlers/chat.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "net/http" "strconv" @@ -61,6 +62,9 @@ func (h *ChatHandler) NewChat(c *echo.Context) error { } func (h *ChatHandler) NewChatMessage(c *echo.Context) error { + + ctx := context.Background() + userID := c.Get(auth.ContextUserIDKey).(string) teamID := c.Param("space") chatID := c.Param("id") @@ -75,7 +79,7 @@ func (h *ChatHandler) NewChatMessage(c *echo.Context) error { chunkChan := make(chan *domain.Chunk) _, err = h.chatService.AddChatMessage( - c.Request().Context(), + ctx, userID, teamID, chatID, diff --git a/backend/internal/service/chat/service.go b/backend/internal/service/chat/service.go index 1499047..06baba5 100644 --- a/backend/internal/service/chat/service.go +++ b/backend/internal/service/chat/service.go @@ -146,6 +146,12 @@ func (s *Service) AddChatMessage( cb, ) + chat, _ := s.repoChat.GetChat(userID, teamID, chatID) + chat.FreshTitle = false + s.repoChat.UpdateChat(userID, chat) + + go s.RefreshTitles(provider, chatModelID, teamID) + return message, nil } diff --git a/backend/internal/service/chat/title.go b/backend/internal/service/chat/title.go new file mode 100644 index 0000000..f996f86 --- /dev/null +++ b/backend/internal/service/chat/title.go @@ -0,0 +1,38 @@ +package chat + +import ( + "context" + "log/slog" + + "trankilou.fr/lassistanoque/backend/internal/domain" +) + +func (s *Service) RefreshTitles(provider *domain.Provider, modelID string, teamID string) { + + ctx := context.Background() + + chats, err := s.repoChat.ListChatsWithUnfreshTitles(teamID) + if err != nil { + slog.Error("RefreshTitles.ListChats", "error", err) + } + for _, chat := range chats { + newchat, _ := s.repoChat.GetChat(chat.UserID, chat.TeamID, chat.ID) + + if !chat.FreshTitle { + messages, err := s.repoChat.GetChatMessages(newchat.UserID, newchat.TeamID, newchat.ID, true) + if err != nil { + slog.Error("RefreshTitles.GetMessages", "error", err) + } + title, err := s.llmEngine.Generate(ctx, provider, modelID, append(messages, &domain.Message{ + Role: string(domain.RoleUser), + Content: "resume toute la conversation dans un titre entre 5 et 10 mots", + })) + if err != nil { + slog.Error("RefreshTitles.Generate", "error", err) + } + newchat.FreshTitle = true + newchat.Title = title + s.repoChat.UpdateChat(newchat.UserID, newchat) + } + } +} diff --git a/backend/lassistanoque.db-wal b/backend/lassistanoque.db-wal index bbca6b4..660073f 100644 Binary files a/backend/lassistanoque.db-wal and b/backend/lassistanoque.db-wal differ diff --git a/backend/web/src/routes/(authenticated)/[space]/(chat)/[conversation]/+page.svelte b/backend/web/src/routes/(authenticated)/[space]/(chat)/[conversation]/+page.svelte index a0c14ec..f5525fb 100644 --- a/backend/web/src/routes/(authenticated)/[space]/(chat)/[conversation]/+page.svelte +++ b/backend/web/src/routes/(authenticated)/[space]/(chat)/[conversation]/+page.svelte @@ -1,24 +1,21 @@