Mise à jour du titre (imparfait)
This commit is contained in:
1 parent
345129c7bf
commit
509a3b14c8
14 files changed
+451
-18
No files matched your search
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user