diff --git a/internal/aiagent/worker.go b/internal/aiagent/worker.go index 2422d3a9..6a58dbfb 100644 --- a/internal/aiagent/worker.go +++ b/internal/aiagent/worker.go @@ -661,7 +661,7 @@ func (m *Manager) messageText(msg cmodels.Message) string { var trimmed string if msg.ContentType == cmodels.ContentTypeHTML { // Render full with the same link-keeping options as trimmed, else the prefix diffing below never matches. - if t := stringutil.HTML2TextWithLinks(msg.Content); t != "" { + if t := stringutil.HTML2TextMarkdownLinks(msg.Content); t != "" { full = t } trimmed = stringutil.HTML2TextNoQuotes(msg.Content) diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index 8e6c35c8..9b9ec370 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -568,7 +568,7 @@ func Transcript(msgs []Message, max int) string { } text := strings.TrimSpace(msg.TextContent) if msg.ContentType == ContentTypeHTML { - if t := stringutil.HTML2TextWithLinks(msg.Content); t != "" { + if t := stringutil.HTML2TextMarkdownLinks(msg.Content); t != "" { text = t } } diff --git a/internal/conversation/models/models_test.go b/internal/conversation/models/models_test.go index 97df683a..cea5be9e 100644 --- a/internal/conversation/models/models_test.go +++ b/internal/conversation/models/models_test.go @@ -33,7 +33,7 @@ func TestTranscript(t *testing.T) { } got := Transcript(msgs, 50) - want := "Customer: My payment on this page ( https://example.com/pay ) failed.\n" + + want := "Customer: My payment on [this page](https://example.com/pay) failed.\n" + "Agent: Looking into it.\n" + "Customer: Any update?\n" if got != want { diff --git a/internal/stringutil/emailquote.go b/internal/stringutil/emailquote.go index d3eb79b1..9b95fcda 100644 --- a/internal/stringutil/emailquote.go +++ b/internal/stringutil/emailquote.go @@ -25,14 +25,14 @@ var ( func HTML2TextNoQuotes(htmlContent string) string { doc, err := html.Parse(strings.NewReader(htmlContent)) if err != nil { - return TrimPlainTextQuotes(HTML2TextWithLinks(htmlContent)) + return TrimPlainTextQuotes(HTML2TextMarkdownLinks(htmlContent)) } pruneQuotedNodes(doc) var b strings.Builder if err := html.Render(&b, doc); err != nil { - return TrimPlainTextQuotes(HTML2TextWithLinks(htmlContent)) + return TrimPlainTextQuotes(HTML2TextMarkdownLinks(htmlContent)) } - return TrimPlainTextQuotes(HTML2TextWithLinks(b.String())) + return TrimPlainTextQuotes(HTML2TextMarkdownLinks(b.String())) } // TrimPlainTextQuotes strips a trailing quoted-reply block (">" lines, "On ... wrote:" and "Original Message" markers) from plain text. diff --git a/internal/stringutil/emailquote_test.go b/internal/stringutil/emailquote_test.go index 1d329f77..ed3fe714 100644 --- a/internal/stringutil/emailquote_test.go +++ b/internal/stringutil/emailquote_test.go @@ -41,7 +41,7 @@ func TestHTML2TextNoQuotes(t *testing.T) { { name: "protonmail", html: `
Hello with quoted.
Sent with Proton Mail secure email.
On Wednesday, July 22nd, 2026 at 9:06 PM, Abhinav <user@example.com> wrote:

Hello!


`, - want: "Hello with quoted.\nSent with Proton Mail ( https://proton.me/mail/home ) secure email.", + want: "Hello with quoted.\nSent with [Proton Mail](https://proton.me/mail/home) secure email.", }, { name: "quote only returns empty", diff --git a/internal/stringutil/htmllinks.go b/internal/stringutil/htmllinks.go new file mode 100644 index 00000000..12894e27 --- /dev/null +++ b/internal/stringutil/htmllinks.go @@ -0,0 +1,67 @@ +package stringutil + +import ( + "strings" + + "github.com/jaytaylor/html2text" + "golang.org/x/net/html" +) + +// HTML2TextMarkdownLinks converts HTML to text keeping links as markdown "[text](url)", the form a +// model copies back into its reply and that Markdown2HTML turns into an anchor again. +func HTML2TextMarkdownLinks(htmlContent string) string { + if doc, err := html.Parse(strings.NewReader(htmlContent)); err == nil { + inlineAnchorMarkdown(doc) + var b strings.Builder + if err := html.Render(&b, doc); err == nil { + htmlContent = b.String() + } + } + return htmlToText(htmlContent, html2text.Options{}) +} + +func inlineAnchorMarkdown(n *html.Node) { + var next *html.Node + for child := n.FirstChild; child != nil; child = next { + next = child.NextSibling + if child.Type == html.ElementNode && child.Data == "a" { + link := markdownLink(nodeText(child), strings.TrimSpace(attrValue(child, "href"))) + if link != "" { + n.InsertBefore(&html.Node{Type: html.TextNode, Data: link}, child) + n.RemoveChild(child) + continue + } + } + inlineAnchorMarkdown(child) + } +} + +// markdownLink returns "" when the anchor is not worth rewriting, leaving it to the text conversion. +func markdownLink(text, href string) string { + if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(href, "cid:") { + return "" + } + text = strings.Join(strings.Fields(text), " ") + if text == "" || text == href { + return href + } + if strings.ContainsAny(href, " ()") { + href = "<" + href + ">" + } + return "[" + strings.NewReplacer("[", `\[`, "]", `\]`).Replace(text) + "](" + href + ")" +} + +func nodeText(n *html.Node) string { + var b strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.TextNode { + b.WriteString(node.Data) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return b.String() +} diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index 04a4f506..72e9ef29 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -48,11 +48,6 @@ func HTML2Text(html string) string { return htmlToText(html, html2text.Options{TextOnly: true}) } -// HTML2TextWithLinks converts HTML to text keeping link URLs as "text ( url )". -func HTML2TextWithLinks(html string) string { - return htmlToText(html, html2text.Options{}) -} - // Markdown2HTML converts markdown to HTML, falling back to the input on error. func Markdown2HTML(md string) string { var b strings.Builder diff --git a/internal/stringutil/stringutil_test.go b/internal/stringutil/stringutil_test.go index 722325f0..d454c9ce 100644 --- a/internal/stringutil/stringutil_test.go +++ b/internal/stringutil/stringutil_test.go @@ -297,16 +297,16 @@ func TestSplitName(t *testing.T) { } } -func TestHTML2TextWithLinks(t *testing.T) { +func TestHTML2TextMarkdownLinks(t *testing.T) { tests := []struct { name string html string want string }{ { - name: "link with distinct text keeps url", + name: "link with distinct text becomes a markdown link", html: `

See the guide for steps.

`, - want: "See the guide ( https://example.com/guide ) for steps.", + want: "See [the guide](https://example.com/guide) for steps.", }, { name: "link text equal to url not duplicated", @@ -318,10 +318,30 @@ func TestHTML2TextWithLinks(t *testing.T) { html: `

No links here.

`, want: "No links here.", }, + { + name: "nested markup inside the anchor flattens to link text", + html: `

Pay now

`, + want: "[Pay now](https://example.com/x)", + }, + { + name: "brackets in link text are escaped", + html: `

Docs [beta]

`, + want: `[Docs \[beta\]](https://example.com)`, + }, + { + name: "url with parentheses is wrapped in angle brackets", + html: `

See

`, + want: "[See]()", + }, + { + name: "anchor without href keeps its text", + html: `

Top of page.

`, + want: "Top of page.", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := HTML2TextWithLinks(tt.html); got != tt.want { + if got := HTML2TextMarkdownLinks(tt.html); got != tt.want { t.Errorf("got %q, want %q", got, tt.want) } })