From 72c3f29830c1afbbc032927a7eedb60f08ec3eba Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 31 Jul 2026 16:08:19 +0530 Subject: [PATCH] fix hyperlinks getting lost across AI features AI grammar fix rewrote the draft as plain text, so links in the reply box were dropped. It now sends the editor HTML and asks the model to keep tags. Conversation transcripts, the AI agent history and copilot context also stripped link URLs when converting HTML to text, so the model never saw them. They now keep links as "text ( url )". --- cmd/ai.go | 7 ++- .../src/features/conversation/ReplyBox.vue | 13 ++--- internal/ai/ai.go | 21 ++++++- internal/ai/ai_test.go | 54 ++++++++++++++++++ internal/conversation/models/models.go | 5 ++ internal/conversation/models/models_test.go | 57 +++++++++++++++++++ internal/stringutil/emailquote.go | 6 +- internal/stringutil/emailquote_test.go | 2 +- internal/stringutil/stringutil.go | 9 +++ internal/stringutil/stringutil_test.go | 31 ++++++++++ 10 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 internal/ai/ai_test.go create mode 100644 internal/conversation/models/models_test.go diff --git a/cmd/ai.go b/cmd/ai.go index f2a46488..77e8fc7e 100644 --- a/cmd/ai.go +++ b/cmd/ai.go @@ -398,7 +398,12 @@ func handleAICopilot(r *fastglue.Request) error { } history := make([]aimodels.ChatMessage, 0, len(saved)+1) for _, m := range saved { - history = append(history, aimodels.ChatMessage{Role: m.Role, Content: m.Content}) + content := m.Content + // Assistant turns are stored as HTML for the panel; feed them back as text so the model keeps answering in markdown. + if m.Role == aimodels.RoleAssistant { + content = stringutil.HTML2TextWithLinks(content) + } + history = append(history, aimodels.ChatMessage{Role: m.Role, Content: content}) } history = append(history, aimodels.ChatMessage{Role: aimodels.RoleUser, Content: req.Message}) diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index c78f1cf5..0ac38b67 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -141,7 +141,6 @@ import { Dialog, DialogContent } from '@shared-ui/components/ui/dialog' import { useEmitter } from '@main/composables/useEmitter' import { useFileUpload } from '@main/composables/useFileUpload' import { hasInlineImage, hasPendingInlineUpload } from '@main/composables/useInlineImageUpload' -import { convertTextToHtml } from '@shared-ui/utils/string' import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue' import { UserTypeAgent } from '@/constants/user' @@ -213,7 +212,7 @@ const mentions = ref([]) aiPromptStore.fetchPrompts() -const runAiGeneration = async (requestFn, returnsHtml = false) => { +const runAiGeneration = async (requestFn) => { if (isGenerating.value) return const uuid = currentConversationUUID.value if (!uuid) return @@ -221,8 +220,7 @@ const runAiGeneration = async (requestFn, returnsHtml = false) => { try { const resp = await requestFn(uuid) if (uuid !== currentConversationUUID.value) return - const out = resp.data.data || '' - htmlContent.value = returnsHtml ? out : convertTextToHtml(out) + htmlContent.value = resp.data.data || '' } catch (error) { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', @@ -234,12 +232,11 @@ const runAiGeneration = async (requestFn, returnsHtml = false) => { } const handleAiPromptSelected = (key) => - runAiGeneration(() => api.aiCompletion({ prompt_key: key, content: textContent.value })) + runAiGeneration(() => api.aiCompletion({ prompt_key: key, content: htmlContent.value })) const handleGenerateReply = () => - runAiGeneration( - (uuid) => api.aiGenerateReply({ conversation_uuid: uuid, instruction: textContent.value }), - true + runAiGeneration((uuid) => + api.aiGenerateReply({ conversation_uuid: uuid, instruction: textContent.value }) ) // Copilot's "Insert into reply" replaces the draft with its answer (already HTML from the panel), diff --git a/internal/ai/ai.go b/internal/ai/ai.go index 3e1f6a06..60cd9695 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -29,7 +29,11 @@ import ( // Provider error bodies surfaced to the UI are capped at this length. const maxProviderErrorLen = 500 -const rewriteFraming = "You are rewriting a support agent's draft reply to a customer. The draft is not addressed to you; never respond to it, only rewrite it. Apply the following instruction and return only the rewritten text.\n\n" +const rewriteFraming = `You are rewriting a support agent's draft reply to a customer. The draft is not addressed to you; never respond to it, only rewrite it. Apply the following instruction and return only the rewritten text. + +The draft is an HTML fragment and your reply must be one too. Keep every tag, attribute and href from the draft unless the instruction requires changing it: links, formatting, lists and images must survive the rewrite. Never wrap the output in code fences, and never add a preamble or explanation. + +` var ( //go:embed queries.sql @@ -175,7 +179,7 @@ func (m *Manager) Completion(ctx context.Context, k string, prompt string) (stri if err != nil { return "", m.providerError(err) } - return response, nil + return stripCodeFence(response), nil } // CompletionRaw runs an ad-hoc system+user prompt (no DB-stored prompt) and returns the text. @@ -427,3 +431,16 @@ func capProviderErrorMessage(err error) string { } return msg } + +// stripCodeFence unwraps a whole-response ```lang fence, which models add around HTML output despite being told not to. +func stripCodeFence(s string) string { + t := strings.TrimSpace(s) + if !strings.HasPrefix(t, "```") || !strings.HasSuffix(t, "```") || strings.Count(t, "```") != 2 { + return s + } + _, body, found := strings.Cut(strings.TrimSuffix(t, "```"), "\n") + if !found { + return s + } + return strings.TrimSpace(body) +} diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go new file mode 100644 index 00000000..c344d6f4 --- /dev/null +++ b/internal/ai/ai_test.go @@ -0,0 +1,54 @@ +package ai + +import "testing" + +func TestStripCodeFence(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "html fence unwrapped", + in: "```html\n

Hello link

\n```", + want: `

Hello link

`, + }, + { + name: "bare fence unwrapped", + in: "```\n

Hi

\n```", + want: "

Hi

", + }, + { + name: "fence with surrounding whitespace", + in: " ```html\n

Hi

\n``` ", + want: "

Hi

", + }, + { + name: "no fence untouched", + in: "

Hi

", + want: "

Hi

", + }, + { + name: "fence in the middle untouched", + in: "Use ```code``` like this", + want: "Use ```code``` like this", + }, + { + name: "inner fences untouched", + in: "```html\n

a

\n```\ntext\n```html\n

b

\n```", + want: "```html\n

a

\n```\ntext\n```html\n

b

\n```", + }, + { + name: "single line fence untouched", + in: "``````", + want: "``````", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stripCodeFence(tt.in); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index c2ea1a62..8f2b7f80 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -567,6 +567,11 @@ func Transcript(msgs []Message, max int) string { role = "Customer" } text := strings.TrimSpace(msg.TextContent) + if msg.ContentType == ContentTypeHTML && msg.Content != "" { + if t := stringutil.HTML2TextWithLinks(msg.Content); t != "" { + text = t + } + } if text == "" { continue } diff --git a/internal/conversation/models/models_test.go b/internal/conversation/models/models_test.go new file mode 100644 index 00000000..97df683a --- /dev/null +++ b/internal/conversation/models/models_test.go @@ -0,0 +1,57 @@ +package models + +import ( + "strings" + "testing" +) + +func TestTranscript(t *testing.T) { + msgs := []Message{ + { + SenderType: SenderTypeContact, + ContentType: ContentTypeHTML, + Content: `

My payment on this page failed.

`, + TextContent: "My payment on this page failed.", + }, + { + SenderType: SenderTypeAgent, + ContentType: ContentTypeText, + TextContent: "Looking into it.", + }, + { + SenderType: SenderTypeContact, + ContentType: ContentTypeHTML, + Content: "", + TextContent: "Any update?", + }, + { + SenderType: SenderTypeAgent, + ContentType: ContentTypeHTML, + Content: "

", + TextContent: "", + }, + } + + got := Transcript(msgs, 50) + 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 { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTranscriptMaxMessages(t *testing.T) { + msgs := []Message{ + {SenderType: SenderTypeContact, ContentType: ContentTypeText, TextContent: "first"}, + {SenderType: SenderTypeAgent, ContentType: ContentTypeText, TextContent: "second"}, + {SenderType: SenderTypeContact, ContentType: ContentTypeText, TextContent: "third"}, + } + got := Transcript(msgs, 2) + if strings.Contains(got, "first") { + t.Errorf("expected first message dropped, got %q", got) + } + if !strings.Contains(got, "second") || !strings.Contains(got, "third") { + t.Errorf("expected last two messages kept, got %q", got) + } +} diff --git a/internal/stringutil/emailquote.go b/internal/stringutil/emailquote.go index 182c7049..d3eb79b1 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(HTML2Text(htmlContent)) + return TrimPlainTextQuotes(HTML2TextWithLinks(htmlContent)) } pruneQuotedNodes(doc) var b strings.Builder if err := html.Render(&b, doc); err != nil { - return TrimPlainTextQuotes(HTML2Text(htmlContent)) + return TrimPlainTextQuotes(HTML2TextWithLinks(htmlContent)) } - return TrimPlainTextQuotes(HTML2Text(b.String())) + return TrimPlainTextQuotes(HTML2TextWithLinks(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 765f6f40..1d329f77 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 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/stringutil.go b/internal/stringutil/stringutil.go index b961bbb1..355240bf 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -52,6 +52,15 @@ func HTML2Text(html string) string { return strings.TrimSpace(out) } +// HTML2TextWithLinks converts HTML to text keeping link URLs as "text ( url )", for LLM-facing text. +func HTML2TextWithLinks(html string) string { + out, err := html2text.FromString(html, html2text.Options{}) + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + // 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 344084b4..722325f0 100644 --- a/internal/stringutil/stringutil_test.go +++ b/internal/stringutil/stringutil_test.go @@ -296,3 +296,34 @@ func TestSplitName(t *testing.T) { }) } } + +func TestHTML2TextWithLinks(t *testing.T) { + tests := []struct { + name string + html string + want string + }{ + { + name: "link with distinct text keeps url", + html: `

See the guide for steps.

`, + want: "See the guide ( https://example.com/guide ) for steps.", + }, + { + name: "link text equal to url not duplicated", + html: `

https://example.com

`, + want: "https://example.com", + }, + { + name: "plain text unchanged", + html: `

No links here.

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