fix(media): match all channel image placeholders in injectPathTags

Different channels emit different placeholder formats — Telegram/Feishu
use [image: photo], WeCom/WeChat/Line use bare [image], QQ/Discord use
[image: <filename>]. The previous string-match code only handled
[image: photo], so for the other channels the path tag was appended as
a duplicate, producing content like "[image] [image:/path]".

Switch to per-type regex that matches all generic placeholder shapes
while leaving path tags ([image:/path]) untouched. Also fixes the same
issue for [audio], [video], [file] tags. Added test coverage for the
various placeholder shapes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guoguo 2026-04-29 03:22:12 -07:00
parent b46e4d4c84
commit 9561476347
2 changed files with 78 additions and 11 deletions

View file

@ -11,6 +11,7 @@ import (
"encoding/base64" "encoding/base64"
"io" "io"
"os" "os"
"regexp"
"strings" "strings"
"github.com/h2non/filetype" "github.com/h2non/filetype"
@ -20,12 +21,23 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// genericPlaceholderRegex matches generic media placeholders emitted by various
// channels: [image], [image: photo], [image: filename.jpg] — but NOT path tags
// like [image:/path/to/file] (path tags have no space after the colon).
var (
imagePlaceholderRegex = regexp.MustCompile(`\[image(:\s+[^\]]*)?\]`)
audioPlaceholderRegex = regexp.MustCompile(`\[audio(:\s+[^\]]*)?\]`)
videoPlaceholderRegex = regexp.MustCompile(`\[video(:\s+[^\]]*)?\]`)
filePlaceholderRegex = regexp.MustCompile(`\[file(:\s+[^\]]*)?\]`)
)
// resolveMediaRefs resolves media:// refs in messages. // resolveMediaRefs resolves media:// refs in messages.
// For user messages: images get path tags only ([image:/path]) so the LLM // For user messages: images get path tags only ([image:/path]) so the LLM
// can decide whether to view them via load_image or operate on the file. // can decide whether to view them via load_image or operate on the file.
// For tool messages: images are base64-encoded and appended as a synthetic // For tool messages: images are base64-encoded and appended as a synthetic
// user message after the contiguous tool-message block ends, preserving // user message only after the contiguous tool-message block ends, so we don't
// the required assistant→tool ordering for LLM APIs. // break the tool-results-must-immediately-follow-assistant constraint that
// LLM APIs enforce.
// Non-image files always get path tags regardless of role. // Non-image files always get path tags regardless of role.
// Returns a new slice; original messages are not mutated. // Returns a new slice; original messages are not mutated.
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
@ -227,24 +239,31 @@ func buildPathTag(mime, localPath string) string {
} }
// injectPathTags replaces generic media tags in content with path-bearing versions, // injectPathTags replaces generic media tags in content with path-bearing versions,
// or appends if no matching generic tag is found. // or appends if no matching generic tag is found. Channels emit a few different
// placeholder formats — [image], [image: photo], [image: filename.jpg] — so we
// match all of them via regex while leaving path tags ([image:/path]) untouched.
func injectPathTags(content string, tags []string) string { func injectPathTags(content string, tags []string) string {
for _, tag := range tags { for _, tag := range tags {
var generic string var pattern *regexp.Regexp
switch { switch {
case strings.HasPrefix(tag, "[image:"): case strings.HasPrefix(tag, "[image:"):
generic = "[image: photo]" pattern = imagePlaceholderRegex
case strings.HasPrefix(tag, "[audio:"): case strings.HasPrefix(tag, "[audio:"):
generic = "[audio]" pattern = audioPlaceholderRegex
case strings.HasPrefix(tag, "[video:"): case strings.HasPrefix(tag, "[video:"):
generic = "[video]" pattern = videoPlaceholderRegex
case strings.HasPrefix(tag, "[file:"): case strings.HasPrefix(tag, "[file:"):
generic = "[file]" pattern = filePlaceholderRegex
} }
if generic != "" && strings.Contains(content, generic) { if pattern != nil {
content = strings.Replace(content, generic, tag, 1) if loc := pattern.FindStringIndex(content); loc != nil {
} else if content == "" { content = content[:loc[0]] + tag + content[loc[1]:]
continue
}
}
if content == "" {
content = tag content = tag
} else { } else {
content += " " + tag content += " " + tag

View file

@ -4992,6 +4992,54 @@ func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {
} }
} }
func TestInjectPathTags_HandlesVariousChannelPlaceholders(t *testing.T) {
cases := []struct {
name string
content string
tag string
want string
}{
// Telegram / Feishu format
{"image_photo", "[image: photo]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
// WeCom / WeChat / Line format
{"bare_image", "[image]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
// QQ / Discord format with filename
{"image_filename", "[image: pic.jpg]", "[image:/tmp/p.png]", "[image:/tmp/p.png]"},
{"audio_with_filename", "[audio: voice.m4a]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"},
{"bare_audio", "[audio]", "[audio:/tmp/a.m4a]", "[audio:/tmp/a.m4a]"},
{"bare_video", "[video]", "[video:/tmp/v.mp4]", "[video:/tmp/v.mp4]"},
{"bare_file", "[file]", "[file:/tmp/f.pdf]", "[file:/tmp/f.pdf]"},
// Mixed surrounding text
{
"with_text",
"hello [image] world",
"[image:/tmp/p.png]",
"hello [image:/tmp/p.png] world",
},
// No placeholder — append
{"no_placeholder", "hello world", "[image:/tmp/p.png]", "hello world [image:/tmp/p.png]"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := injectPathTags(tc.content, []string{tc.tag})
if got != tc.want {
t.Errorf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestInjectPathTags_DoesNotReplacePathTag(t *testing.T) {
// If content already contains a path tag, we must not touch it.
content := "see [image:/already/placed.png] thanks"
got := injectPathTags(content, []string{"[image:/new/path.png]"})
want := "see [image:/already/placed.png] thanks [image:/new/path.png]"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) { func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
dir := t.TempDir() dir := t.TempDir()