Mise à jour du titre (imparfait)

This commit is contained in:
fabien committed 2026-10-05 22:05:07 +02:00
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
+34
View File
@@ -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, &params); 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
}
+8
View File
@@ -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)
+5 -1
View File
@@ -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,
+6
View File
@@ -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
}
+38
View File
@@ -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)
}
}
}