From e5ca6771134895dbb3a28da99cce926ab1372987 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 31 May 2026 21:03:04 +0100 Subject: [PATCH] =?UTF-8?q?feat(translate):=20handle=20canonical.ImageBloc?= =?UTF-8?q?k=20=E2=86=92=20Anthropic=20image=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks the most efficient source shape available: URL set → {"type":"url", "url":...} DataBase64 set → {"type":"base64", "media_type":..., "data":...} Path set → read file → base64 → {"type":"base64", ...} MIME defaults to application/octet-stream when ImageBlock.MediaType is empty (some servers accept that, some don't — operator's responsibility to fill it in upstream). File read failure → emits a "[image %q unavailable]" text block in its place so the message still goes through with provenance preserved. Verified live against Kimi K2.6 via Fabric channel: alice uploads blue 32x32 PNG → Fabric plugin downloads + emits Media in inbound → host turncore builds canonical.Message with ImageBlock → translator reads file + base64s → Kimi responds "Blue". --- translate/translate.go | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/translate/translate.go b/translate/translate.go index 4ce71e9..0a2bac9 100644 --- a/translate/translate.go +++ b/translate/translate.go @@ -8,8 +8,10 @@ package translate import ( + "encoding/base64" "encoding/json" "fmt" + "os" "git.hangman-lab.top/hzhang/Plexum-sdk-go/canonical" @@ -74,6 +76,40 @@ func CanonicalToAnthropic(req canonical.TurnRequest, modelID string, defaultMaxT return out, nil } +// imageSource converts a canonical.ImageBlock into the +// Anthropic-shaped {"type":..., ...} source map. Picks the most +// efficient source mode available; returns nil if all sources are +// empty or the path is unreadable. +// +// URL set → {"type":"url", "url":...} +// DataBase64 set → {"type":"base64", "media_type":..., "data":...} +// Path set → read file → base64 → {"type":"base64", ...} +func imageSource(v *canonical.ImageBlock) map[string]any { + media := v.MediaType + if media == "" { + media = "application/octet-stream" + } + if v.URL != "" { + return map[string]any{"type": "url", "url": v.URL} + } + if v.DataBase64 != "" { + return map[string]any{ + "type": "base64", "media_type": media, "data": v.DataBase64, + } + } + if v.Path == "" { + return nil + } + raw, err := os.ReadFile(v.Path) + if err != nil { + return nil + } + return map[string]any{ + "type": "base64", "media_type": media, + "data": base64.StdEncoding.EncodeToString(raw), + } +} + func roleToAnthropic(r canonical.Role) string { switch r { case canonical.RoleUser: @@ -129,6 +165,16 @@ func blockToAnthropic(b canonical.Block) anthropic.ContentBlock { return anthropic.ContentBlock{ "type": "thinking", "thinking": v.Thinking, "signature": v.Signature, } + case *canonical.ImageBlock: + // Three source shapes possible; emit whatever the canonical + // block carries. Anthropic accepts base64 + url + (vendor- + // specific) file shape. Path → read + base64 (default). + src := imageSource(v) + if src == nil { + return anthropic.ContentBlock{"type": "text", + "text": fmt.Sprintf("[image %q unavailable]", v.Path)} + } + return anthropic.ContentBlock{"type": "image", "source": src} default: // Best-effort generic: marshal then unmarshal into a map. raw, err := json.Marshal(b)