68427ad91a
Derive an ordered map of each inline reference image to the surrounding prompt text describing its role, and feed it into the decision, research, task-plan, conversation and image-prompt builders. The image prompt now carries this placement so reference roles are not swapped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
274 lines
8.1 KiB
Go
274 lines
8.1 KiB
Go
package piagent
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"img_infinite_canvas/internal/domain/design"
|
||
)
|
||
|
||
type inlineReferenceMatch struct {
|
||
start int
|
||
end int
|
||
index int
|
||
name string
|
||
url string
|
||
}
|
||
|
||
var (
|
||
inlineReferenceDirectivePattern = regexp.MustCompile(`(?i)(参考图|reference image)\s*(\d+)?\s*(?:[((]([^))\n]+)[))])?\s*[::]\s*(https?://[^\s\],。]+|data:image/[^\s\],。]+|/[^\s\],。]+|attachment://reference-\d+)`)
|
||
inlinePromptImageTokenPattern = regexp.MustCompile(`\[@image:#(\d+):([^:\]]+):(?:\[([^\]]+)\]\(([^)]+)\)|([^\]]+))\]`)
|
||
)
|
||
|
||
func inlineReferencePlacementContext(prompt string, messages []design.AgentChatMessage) string {
|
||
messageText := agentMessageInlineReferenceText(messages)
|
||
source := prompt
|
||
if len(collectInlineReferenceMatches(messageText)) > 0 && (len(collectInlineReferenceMatches(prompt)) == 0 || utf8.RuneCountInString(messageText) >= utf8.RuneCountInString(prompt)) {
|
||
source = messageText
|
||
}
|
||
return inlineReferencePlacementContextFromText(source, prefersEnglish(prompt+"\n"+messageText))
|
||
}
|
||
|
||
func inlineReferencePlacementContextFromText(text string, english bool) string {
|
||
matches := collectInlineReferenceMatches(text)
|
||
if len(matches) == 0 {
|
||
return "none"
|
||
}
|
||
if len(matches) > 16 {
|
||
matches = matches[:16]
|
||
}
|
||
|
||
lines := make([]string, 0, len(matches)+1)
|
||
if english {
|
||
lines = append(lines, "Image input order follows the inline reference order. Nearby text before or after a reference is semantic role/context for that image.")
|
||
} else {
|
||
lines = append(lines, "图片输入顺序按参考图出现顺序;某张图前后的相邻文字是这张图的角色/用途线索。")
|
||
}
|
||
for order, match := range matches {
|
||
beforeStart := 0
|
||
if order > 0 {
|
||
beforeStart = matches[order-1].end
|
||
}
|
||
afterEnd := len(text)
|
||
if order+1 < len(matches) {
|
||
afterEnd = matches[order+1].start
|
||
}
|
||
before := compactReferenceNeighbor(text[beforeStart:match.start], true)
|
||
after := compactReferenceNeighbor(text[match.end:afterEnd], false)
|
||
if english {
|
||
lines = append(lines, englishReferencePlacementLine(order+1, match, before, after))
|
||
continue
|
||
}
|
||
lines = append(lines, chineseReferencePlacementLine(order+1, match, before, after))
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func agentMessageInlineReferenceText(messages []design.AgentChatMessage) string {
|
||
var builder strings.Builder
|
||
imageIndex := 0
|
||
writeText := func(text string) {
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
return
|
||
}
|
||
if builder.Len() > 0 {
|
||
builder.WriteString(" ")
|
||
}
|
||
builder.WriteString(text)
|
||
}
|
||
for _, message := range messages {
|
||
if strings.TrimSpace(message.Role) != "" && strings.ToLower(strings.TrimSpace(message.Role)) != "user" {
|
||
continue
|
||
}
|
||
for _, content := range message.Contents {
|
||
switch strings.ToLower(strings.TrimSpace(content.Type)) {
|
||
case "text":
|
||
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
|
||
continue
|
||
}
|
||
writeText(stripInlineImageGeneratorDirectiveLines(content.Text))
|
||
case "image", "mask":
|
||
imageIndex++
|
||
name := compactInlineReferenceName(content.ImageName)
|
||
if name == "" {
|
||
name = "image"
|
||
}
|
||
url := sanitizeReferenceURL(content.ImageURL)
|
||
if url == "" {
|
||
url = fmt.Sprintf("attachment://reference-%d", imageIndex)
|
||
}
|
||
writeText(fmt.Sprintf("参考图 %d(%s):%s", imageIndex, name, url))
|
||
}
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
func collectInlineReferenceMatches(text string) []inlineReferenceMatch {
|
||
if strings.TrimSpace(text) == "" {
|
||
return nil
|
||
}
|
||
matches := make([]inlineReferenceMatch, 0)
|
||
|
||
for _, indexes := range inlinePromptImageTokenPattern.FindAllStringSubmatchIndex(text, -1) {
|
||
match := inlineReferenceMatch{start: indexes[0], end: indexes[1]}
|
||
match.index = atoiSubmatch(text, indexes, 2)
|
||
match.name = submatch(text, indexes, 4)
|
||
match.url = sanitizeReferenceURL(firstNonEmptySubmatch(text, indexes, 8, 10))
|
||
if match.url != "" {
|
||
matches = append(matches, match)
|
||
}
|
||
}
|
||
|
||
for _, indexes := range inlineReferenceDirectivePattern.FindAllStringSubmatchIndex(text, -1) {
|
||
match := inlineReferenceMatch{start: indexes[0], end: indexes[1]}
|
||
match.index = atoiSubmatch(text, indexes, 4)
|
||
match.name = submatch(text, indexes, 6)
|
||
match.url = sanitizeReferenceURL(submatch(text, indexes, 8))
|
||
if match.url != "" {
|
||
matches = append(matches, match)
|
||
}
|
||
}
|
||
|
||
sort.SliceStable(matches, func(i, j int) bool {
|
||
if matches[i].start == matches[j].start {
|
||
return matches[i].end > matches[j].end
|
||
}
|
||
return matches[i].start < matches[j].start
|
||
})
|
||
|
||
deduped := make([]inlineReferenceMatch, 0, len(matches))
|
||
for _, match := range matches {
|
||
overlaps := false
|
||
for _, existing := range deduped {
|
||
if match.start < existing.end && match.end > existing.start {
|
||
overlaps = true
|
||
break
|
||
}
|
||
}
|
||
if !overlaps {
|
||
deduped = append(deduped, match)
|
||
}
|
||
}
|
||
return deduped
|
||
}
|
||
|
||
func chineseReferencePlacementLine(order int, match inlineReferenceMatch, before string, after string) string {
|
||
parts := []string{fmt.Sprintf("- %s", referenceDisplayName(order, match, false))}
|
||
if before != "" {
|
||
parts = append(parts, fmt.Sprintf("前文“%s”", before))
|
||
}
|
||
if after != "" {
|
||
parts = append(parts, fmt.Sprintf("后文“%s”", after))
|
||
}
|
||
if len(parts) == 1 {
|
||
parts = append(parts, "无相邻文字")
|
||
}
|
||
return strings.Join(parts, ":")
|
||
}
|
||
|
||
func englishReferencePlacementLine(order int, match inlineReferenceMatch, before string, after string) string {
|
||
parts := []string{fmt.Sprintf("- %s", referenceDisplayName(order, match, true))}
|
||
if before != "" {
|
||
parts = append(parts, fmt.Sprintf("previous text %q", before))
|
||
}
|
||
if after != "" {
|
||
parts = append(parts, fmt.Sprintf("next text %q", after))
|
||
}
|
||
if len(parts) == 1 {
|
||
parts = append(parts, "no adjacent text")
|
||
}
|
||
return strings.Join(parts, ": ")
|
||
}
|
||
|
||
func referenceDisplayName(order int, match inlineReferenceMatch, english bool) string {
|
||
name := compactInlineReferenceName(match.name)
|
||
if name == "" {
|
||
name = "image"
|
||
}
|
||
if english {
|
||
label := fmt.Sprintf("Reference image %d", order)
|
||
if match.index > 0 && match.index != order {
|
||
label += fmt.Sprintf(" / user label %d", match.index)
|
||
}
|
||
return fmt.Sprintf("%s (%s)", label, name)
|
||
}
|
||
label := fmt.Sprintf("参考图 %d", order)
|
||
if match.index > 0 && match.index != order {
|
||
label += fmt.Sprintf(" / 用户标注 %d", match.index)
|
||
}
|
||
return fmt.Sprintf("%s(%s)", label, name)
|
||
}
|
||
|
||
func compactReferenceNeighbor(value string, keepTail bool) string {
|
||
value = strings.ReplaceAll(value, "\u00a0", " ")
|
||
value = imagePromptURLPattern.ReplaceAllString(value, "[参考图]")
|
||
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
|
||
value = strings.Trim(value, " \t\r\n,。,.;;、")
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
const maxRunes = 72
|
||
if utf8.RuneCountInString(value) <= maxRunes {
|
||
return value
|
||
}
|
||
runes := []rune(value)
|
||
if keepTail {
|
||
return "..." + string(runes[len(runes)-maxRunes:])
|
||
}
|
||
return string(runes[:maxRunes]) + "..."
|
||
}
|
||
|
||
func sanitizeReferenceURL(value string) string {
|
||
return strings.TrimSpace(strings.TrimRight(value, ",。,.;;、"))
|
||
}
|
||
|
||
func compactInlineReferenceName(value string) string {
|
||
return strings.Join(strings.Fields(value), " ")
|
||
}
|
||
|
||
func stripInlineImageGeneratorDirectiveLines(text string) string {
|
||
lines := strings.Split(text, "\n")
|
||
filtered := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), "image generator target node:") {
|
||
continue
|
||
}
|
||
filtered = append(filtered, line)
|
||
}
|
||
return strings.TrimSpace(strings.Join(filtered, "\n"))
|
||
}
|
||
|
||
func submatch(text string, indexes []int, offset int) string {
|
||
if offset+1 >= len(indexes) || indexes[offset] < 0 || indexes[offset+1] < 0 {
|
||
return ""
|
||
}
|
||
return text[indexes[offset]:indexes[offset+1]]
|
||
}
|
||
|
||
func firstNonEmptySubmatch(text string, indexes []int, offsets ...int) string {
|
||
for _, offset := range offsets {
|
||
if value := submatch(text, indexes, offset); strings.TrimSpace(value) != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func atoiSubmatch(text string, indexes []int, offset int) int {
|
||
value := strings.TrimSpace(submatch(text, indexes, offset))
|
||
if value == "" {
|
||
return 0
|
||
}
|
||
number, err := strconv.Atoi(value)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return number
|
||
}
|