Skip to content

Commit 5ed20ca

Browse files
authored
Merge pull request #148 from itmisx/fix/ocr-repeat-loop-146
🐛 fix: ocr-repeat-loop
2 parents 5d976d0 + 6d3ce45 commit 5ed20ca

6 files changed

Lines changed: 189 additions & 1 deletion

File tree

agent/image_render.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,49 @@ func ocrTargetsInlinedImage(argsJSON string, convo []ChatMessage) bool {
208208
return false
209209
}
210210

211+
// priorOCRResult 在历史里查这个图片路径是否「已经 OCR 过」:命中则返回上次结果文本 + true。
212+
//
213+
// 用途:用户在提示词里给了图片路径(纯文本,赖在历史里删不掉),模型每轮看到路径就反复调 OCR,
214+
// 用户说"别识别了"也拦不住(issue #146)。据此短路:第二次起不再真跑 OCR,直接回上次结果。
215+
//
216+
// 关联方式:OCR 的目标路径在 assistant 消息的 tool_calls[].arguments 里,而结果在对应的 tool 消息正文里,
217+
// 两者靠 tool_call_id 配对。先建 id→path 映射(只收 OCR 调用),再找该路径对应的 OCR 结果消息。
218+
func priorOCRResult(argsJSON string, convo []ChatMessage) (string, bool) {
219+
var args struct {
220+
Path string `json:"path"`
221+
}
222+
if json.Unmarshal([]byte(argsJSON), &args) != nil {
223+
return "", false
224+
}
225+
target := filepath.Clean(strings.TrimSpace(args.Path))
226+
if target == "" {
227+
return "", false
228+
}
229+
ocrCallPath := map[string]string{} // tool_call_id → OCR 目标路径
230+
for _, m := range convo {
231+
for _, tc := range m.ToolCalls {
232+
if tc.Function.Name != "OCR" {
233+
continue
234+
}
235+
var a struct {
236+
Path string `json:"path"`
237+
}
238+
if json.Unmarshal([]byte(tc.Function.Arguments), &a) != nil {
239+
continue
240+
}
241+
if p := filepath.Clean(strings.TrimSpace(a.Path)); p != "" {
242+
ocrCallPath[tc.ID] = p
243+
}
244+
}
245+
}
246+
for _, m := range convo {
247+
if m.Role == "tool" && ocrCallPath[m.ToolCallID] == target {
248+
return m.Content, true
249+
}
250+
}
251+
return "", false
252+
}
253+
211254
// isImageInputUnsupported 判断错误是否是"该端点/模型不接受图片输入"(发了 base64 才会撞)。
212255
// 命中则上层把该模型降级为无视觉、改走 OCR 重发。匹配宽松些以兼容各家措辞。
213256
// - MiMo: HTTP 404 "No endpoints found that support image input"

agent/image_render_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package agent
2+
3+
import "testing"
4+
5+
// priorOCRResult 用于「同一图片路径已 OCR 过就别重复」的短路判定(issue #146)。
6+
func TestPriorOCRResult(t *testing.T) {
7+
convo := []ChatMessage{
8+
{Role: "user", Content: "图在 /tmp/a.png,别识别了"},
9+
{Role: "assistant", ToolCalls: []ToolCall{
10+
{ID: "c1", Function: ToolCallFunc{Name: "OCR", Arguments: `{"path":"/tmp/a.png"}`}},
11+
}},
12+
{Role: "tool", ToolCallID: "c1", Name: "OCR", Content: "(图片中未识别到文字)"},
13+
}
14+
15+
t.Run("同一路径命中并回上次结果", func(t *testing.T) {
16+
prev, ok := priorOCRResult(`{"path":"/tmp/a.png"}`, convo)
17+
if !ok || prev != "(图片中未识别到文字)" {
18+
t.Fatalf("应命中并返回上次结果,got %q ok=%v", prev, ok)
19+
}
20+
})
21+
22+
t.Run("等价路径经Clean后仍命中", func(t *testing.T) {
23+
if _, ok := priorOCRResult(`{"path":"/tmp/./a.png"}`, convo); !ok {
24+
t.Fatalf("等价路径应命中")
25+
}
26+
})
27+
28+
t.Run("不同路径不命中", func(t *testing.T) {
29+
if _, ok := priorOCRResult(`{"path":"/tmp/b.png"}`, convo); ok {
30+
t.Fatalf("不同路径不应命中")
31+
}
32+
})
33+
34+
t.Run("无历史OCR不命中", func(t *testing.T) {
35+
fresh := []ChatMessage{{Role: "user", Content: "hi"}}
36+
if _, ok := priorOCRResult(`{"path":"/tmp/a.png"}`, fresh); ok {
37+
t.Fatalf("无先前 OCR 不应命中")
38+
}
39+
})
40+
41+
t.Run("坏参数不panic不命中", func(t *testing.T) {
42+
if _, ok := priorOCRResult(`not json`, convo); ok {
43+
t.Fatalf("坏参数不应命中")
44+
}
45+
})
46+
}

agent/llm.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,13 @@ func StartStream(
889889
Output: "你是视觉模型,这张图已经以图片形式内联在当前对话里了,请直接查看图片作答 —— 不要调用 OCR,也不要用 ls/find 去文件系统查找图片文件。",
890890
Success: false,
891891
}
892+
} else if prev, done := priorOCRResult(tc.Function.Arguments, convo); done {
893+
// 同一图片路径已 OCR 过 → 别重复:路径赖在历史里,模型会反复 OCR、用户喊停也拦不住(issue #146)。
894+
// 回上次结果 + 硬性提示别再调,Success=false 让 maxNoProgressRounds 卡死断路器能介入。
895+
result = tools.ToolResult{
896+
Output: "这张图之前已经 OCR 过了,结果是:\n" + prev + "\n\n请勿对同一张图重复 OCR。若仍需要图中信息,请直接询问用户图里写了什么,或让用户改用支持视觉的模型。",
897+
Success: false,
898+
}
892899
} else {
893900
result = executeTool(tc, mode, &lastFile)
894901
}

tools/img_ocr.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@ func ImgOCR(args map[string]any) ToolResult {
4242
return ToolResult{Output: fmt.Sprintf("OCR 失败: %v", err), Success: false}
4343
}
4444
if strings.TrimSpace(text) == "" {
45-
return ToolResult{Output: "(图片中未识别到文字)", Success: true}
45+
// 没识别到文字必须算「失败」:否则非视觉模型对一张读不出字的图反复 OCR,每轮都 Success=true
46+
// 会把 maxNoProgressRounds 卡死断路器一直归零 → 无限重试(issue #146)。返回失败让断路器能介入,
47+
// 并明确叫模型别再对同一张图 OCR。
48+
return ToolResult{
49+
Output: "(图片中未识别到文字)。请勿对同一张图重复 OCR;如需图中内容,请让用户改用支持视觉的模型,或直接询问用户图里写了什么。",
50+
Success: false,
51+
}
4652
}
4753
return ToolResult{Output: text, Success: true}
4854
}

tui/image_attach_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package tui
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
// reconcileAttachedImages 按输入框残留的 [Image #N] 占位符裁剪待发图片列表(issue #146:删不掉)。
9+
func TestReconcileAttachedImages(t *testing.T) {
10+
imgs := []string{"/a.png", "/b.png", "/c.png"} // #1 #2 #3
11+
12+
cases := []struct {
13+
name string
14+
text string
15+
want []string
16+
}{
17+
{
18+
name: "全部保留",
19+
text: "看看 [Image #1] [Image #2] [Image #3]",
20+
want: []string{"/a.png", "/b.png", "/c.png"},
21+
},
22+
{
23+
name: "删中间一张",
24+
text: "看看 [Image #1] [Image #3]",
25+
want: []string{"/a.png", "/c.png"},
26+
},
27+
{
28+
name: "删首张",
29+
text: "[Image #2] [Image #3]",
30+
want: []string{"/b.png", "/c.png"},
31+
},
32+
{
33+
name: "全删光",
34+
text: "纯文字,占位符都删了",
35+
want: []string{},
36+
},
37+
{
38+
name: "顺序无关,按编号对应",
39+
text: "[Image #3] 在前 [Image #1] 在后",
40+
want: []string{"/a.png", "/c.png"},
41+
},
42+
}
43+
44+
for _, c := range cases {
45+
t.Run(c.name, func(t *testing.T) {
46+
got := reconcileAttachedImages(c.text, imgs)
47+
if !reflect.DeepEqual(got, c.want) {
48+
t.Fatalf("reconcileAttachedImages(%q) = %v, want %v", c.text, got, c.want)
49+
}
50+
})
51+
}
52+
}
53+
54+
func TestReconcileAttachedImages_Empty(t *testing.T) {
55+
// 没有附件时原样返回,不 panic。
56+
if got := reconcileAttachedImages("随便什么 [Image #1]", nil); len(got) != 0 {
57+
t.Fatalf("空附件应返回空,got %v", got)
58+
}
59+
}

tui/model.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,9 @@ func (m *model) applyStaleShadow() {
951951

952952
func (m model) submitUserInput(input string) (model, tea.Cmd) {
953953
input = strings.TrimSpace(input)
954+
// 删掉输入框里的 [Image #N] = 想移除那张图:据残留占位符裁剪待发图片列表,
955+
// 否则删了占位符图却照发、非视觉模型还会反复 OCR(issue #146 的「删不掉」)。
956+
m.attachedImagePaths = reconcileAttachedImages(input, m.attachedImagePaths)
954957
if input == "" && len(m.attachedImagePaths) == 0 {
955958
return m, nil
956959
}
@@ -2919,6 +2922,30 @@ func (m *model) insertImagePlaceholder(n int) {
29192922
m.input.InsertString(fmt.Sprintf(" [Image #%d] ", n))
29202923
}
29212924

2925+
// reconcileAttachedImages 按输入框文本里还残留哪些 [Image #N] 占位符,裁剪待发图片列表。
2926+
// 占位符编号 N 对应 attached[N-1](insertImagePlaceholder 插入时 N = 该图在列表里的序号,
2927+
// 列表只增不减直到发送,故编号与下标始终对齐)。用户删掉某个 [Image #N] 文本 = 想移除那张图,
2928+
// 这里把没了占位符的图从待发列表剔除 —— 让「删占位符」真正等于「不发那张图」(issue #146)。
2929+
func reconcileAttachedImages(text string, attached []string) []string {
2930+
if len(attached) == 0 {
2931+
return attached
2932+
}
2933+
present := make(map[int]bool)
2934+
for _, mt := range imagePlaceholderRe.FindAllStringSubmatch(text, -1) {
2935+
var n int
2936+
if _, err := fmt.Sscanf(mt[1], "%d", &n); err == nil {
2937+
present[n] = true
2938+
}
2939+
}
2940+
kept := make([]string, 0, len(attached))
2941+
for i, p := range attached {
2942+
if present[i+1] {
2943+
kept = append(kept, p)
2944+
}
2945+
}
2946+
return kept
2947+
}
2948+
29222949
// roleKind 把内部 role 名映射成 chatLog 段 kind。
29232950
// 决定渲染时套哪根色条 — 不再在 raw 里加任何 "**emoji 名**: " 前缀,
29242951
// 身份完全由色条承载,正文跟用户输入保持原貌。

0 commit comments

Comments
 (0)