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: "```\nHi
\n```", + want: "Hi
", + }, + { + name: "fence with surrounding whitespace", + in: " ```html\nHi
\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\na
\n```\ntext\n```html\nb
\n```", + want: "```html\na
\n```\ntext\n```html\nb
\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!
See the guide for steps.
`, + want: "See the guide ( https://example.com/guide ) for steps.", + }, + { + name: "link text equal to url not duplicated", + html: ``, + 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) + } + }) + } +}