Support read image

This commit is contained in:
郑非
2026-08-06 21:05:08 +08:00
parent 8c4baf7ade
commit b475166ba8
6 changed files with 291 additions and 193 deletions
+5 -2
View File
@@ -119,7 +119,7 @@ tasks:
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}' USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
create:zip: create:zip:
summary: 生成 Windows ZIP 包(包含 exe 与 certs summary: 生成 Windows ZIP 包(包含 exe
internal: true internal: true
vars: vars:
ARCH: '{{.ARCH | default ARCH}}' ARCH: '{{.ARCH | default ARCH}}'
@@ -144,7 +144,10 @@ tasks:
- rm -rf "{{.STAGING_DIR}}" - rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}" - mkdir -p "{{.STAGING_DIR}}"
- cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/" - cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/"
- (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .) - cmd: powershell -NoProfile -Command "Compress-Archive -Path '{{.STAGING_DIR}}/*' -DestinationPath '{{.BIN_DIR}}/{{.ZIP_NAME}}' -Force"
platforms: [windows]
- cmd: (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .)
platforms: [darwin, linux]
- rm -rf "{{.STAGING_DIR}}" - rm -rf "{{.STAGING_DIR}}"
- rm -f "{{.OUTPUT}}" - rm -f "{{.OUTPUT}}"
+28 -28
View File
@@ -19,8 +19,8 @@
"refs": [ "refs": [
{ {
"file": "src/views/ModelConfig.vue", "file": "src/views/ModelConfig.vue",
"line": 256, "line": 255,
"column": 1 "column": 165
} }
] ]
}, },
@@ -137,8 +137,8 @@
"refs": [ "refs": [
{ {
"file": "src/views/Config.vue", "file": "src/views/Config.vue",
"line": 54, "line": 53,
"column": 1 "column": 48
} }
] ]
}, },
@@ -173,8 +173,8 @@
"refs": [ "refs": [
{ {
"file": "src/components/HomeMetricsCard.vue", "file": "src/components/HomeMetricsCard.vue",
"line": 338, "line": 337,
"column": 1 "column": 65
} }
] ]
}, },
@@ -400,8 +400,8 @@
"refs": [ "refs": [
{ {
"file": "src/views/Config.vue", "file": "src/views/Config.vue",
"line": 68, "line": 67,
"column": 1 "column": 48
} }
] ]
}, },
@@ -541,8 +541,8 @@
"refs": [ "refs": [
{ {
"file": "src/components/ModelAdapterTestCard.vue", "file": "src/components/ModelAdapterTestCard.vue",
"line": 151, "line": 150,
"column": 1 "column": 7
} }
] ]
}, },
@@ -589,8 +589,8 @@
"refs": [ "refs": [
{ {
"file": "src/components/ModelAdapterTestCard.vue", "file": "src/components/ModelAdapterTestCard.vue",
"line": 130, "line": 129,
"column": 1 "column": 60
} }
] ]
}, },
@@ -698,8 +698,8 @@
}, },
{ {
"file": "src/components/CursorAccountCard.vue", "file": "src/components/CursorAccountCard.vue",
"line": 164, "line": 163,
"column": 1 "column": 11
} }
] ]
}, },
@@ -948,8 +948,8 @@
"refs": [ "refs": [
{ {
"file": "src/components/ModelAdapterTestCard.vue", "file": "src/components/ModelAdapterTestCard.vue",
"line": 125, "line": 124,
"column": 1 "column": 9
} }
] ]
}, },
@@ -1569,8 +1569,8 @@
"refs": [ "refs": [
{ {
"file": "src/components/HomeMetricsCard.vue", "file": "src/components/HomeMetricsCard.vue",
"line": 390, "line": 389,
"column": 1 "column": 65
} }
] ]
}, },
@@ -1822,8 +1822,8 @@
"refs": [ "refs": [
{ {
"file": "src/components/HomeMetricsCard.vue", "file": "src/components/HomeMetricsCard.vue",
"line": 342, "line": 341,
"column": 1 "column": 23
} }
] ]
}, },
@@ -1995,8 +1995,8 @@
"refs": [ "refs": [
{ {
"file": "src/views/ModelEditor.vue", "file": "src/views/ModelEditor.vue",
"line": 449, "line": 448,
"column": 1 "column": 97
} }
] ]
}, },
@@ -2772,8 +2772,8 @@
"refs": [ "refs": [
{ {
"file": "src/components/CursorAccountCard.vue", "file": "src/components/CursorAccountCard.vue",
"line": 146, "line": 145,
"column": 1 "column": 53
} }
] ]
}, },
@@ -2905,8 +2905,8 @@
"refs": [ "refs": [
{ {
"file": "src/views/Config.vue", "file": "src/views/Config.vue",
"line": 80, "line": 79,
"column": 1 "column": 48
} }
] ]
}, },
@@ -2982,8 +2982,8 @@
"refs": [ "refs": [
{ {
"file": "src/components/CursorAccountCard.vue", "file": "src/components/CursorAccountCard.vue",
"line": 149, "line": 148,
"column": 1 "column": 81
} }
] ]
}, },
+16 -1
View File
@@ -4,6 +4,7 @@ package execbridge
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -2285,6 +2286,18 @@ func buildReadMcpResourceCompletedToolCall(argsJSON []byte, result *agentv1.Read
} }
} }
func isSupportedReadImage(data []byte) bool {
if len(data) == 0 {
return false
}
switch strings.ToLower(strings.TrimSpace(http.DetectContentType(data))) {
case "image/png", "image/jpeg", "image/gif", "image/webp":
return true
default:
return false
}
}
// convertReadResultToReadToolResult 把 `ReadResult` 映射为 `ReadToolResult`。 // convertReadResultToReadToolResult 把 `ReadResult` 映射为 `ReadToolResult`。
func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.ReadToolResult { func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.ReadToolResult {
if result == nil { if result == nil {
@@ -2318,7 +2331,9 @@ func convertReadResultToReadToolResult(result *agentv1.ReadResult) *agentv1.Read
if content != "" { if content != "" {
toolSuccess.Output = &agentv1.ReadToolSuccess_Content{Content: content} toolSuccess.Output = &agentv1.ReadToolSuccess_Content{Content: content}
} else if len(data) > 0 { } else if len(data) > 0 {
if len(data) > readReplayBinaryLimit { if isSupportedReadImage(data) {
toolSuccess.Output = &agentv1.ReadToolSuccess_Data{Data: append([]byte(nil), data...)}
} else if len(data) > readReplayBinaryLimit {
toolSuccess.ExceededLimit = true toolSuccess.ExceededLimit = true
toolSuccess.Output = &agentv1.ReadToolSuccess_Content{ toolSuccess.Output = &agentv1.ReadToolSuccess_Content{
Content: replayTruncationNotice("Read binary data", readReplayBinaryLimit, 0, len(data)), Content: replayTruncationNotice("Read binary data", readReplayBinaryLimit, 0, len(data)),
+19 -2
View File
@@ -1126,7 +1126,16 @@ func isAnthropicCacheableBlock(block map[string]any) bool {
case contentPartTypeText: case contentPartTypeText:
return strings.TrimSpace(anthropicStringField(block, "text")) != "" return strings.TrimSpace(anthropicStringField(block, "text")) != ""
case "tool_result": case "tool_result":
return strings.TrimSpace(anthropicStringField(block, "content")) != "" switch content := block["content"].(type) {
case string:
return strings.TrimSpace(content) != ""
case []map[string]any:
return len(content) > 0
case []any:
return len(content) > 0
default:
return false
}
case "tool_use": case "tool_use":
return strings.TrimSpace(anthropicStringField(block, "id")) != "" && strings.TrimSpace(anthropicStringField(block, "name")) != "" return strings.TrimSpace(anthropicStringField(block, "id")) != "" && strings.TrimSpace(anthropicStringField(block, "name")) != ""
default: default:
@@ -1168,10 +1177,18 @@ func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, r
if toolUseID == "" { if toolUseID == "" {
return nil, nil, fmt.Errorf("anthropic tool message requires tool_call_id") return nil, nil, fmt.Errorf("anthropic tool message requires tool_call_id")
} }
var content any = message.Content
if hasImageContentParts(message.ContentParts) {
contentBlocks, err := anthropicContentBlocks(message)
if err != nil {
return nil, nil, err
}
content = contentBlocks
}
pendingToolResults = append(pendingToolResults, map[string]any{ pendingToolResults = append(pendingToolResults, map[string]any{
"type": "tool_result", "type": "tool_result",
"tool_use_id": toolUseID, "tool_use_id": toolUseID,
"content": message.Content, "content": content,
}) })
case "user", "assistant": case "user", "assistant":
flushToolResults() flushToolResults()
+9 -1
View File
@@ -1967,10 +1967,18 @@ func normalizeOpenAIResponsesInput(messages []Message) (string, []map[string]any
} }
if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" { if role == "tool" && strings.TrimSpace(message.ToolCallID) != "" {
callID := openAIResponsesToolMessageCallID(message, responsesCallIDs) callID := openAIResponsesToolMessageCallID(message, responsesCallIDs)
var output any = openAIResponsesMessageText(message)
if hasImageContentParts(message.ContentParts) {
content, err := openAIResponsesMessageContent(message, false)
if err != nil {
return "", nil, err
}
output = content
}
items = append(items, map[string]any{ items = append(items, map[string]any{
"type": "function_call_output", "type": "function_call_output",
"call_id": callID, "call_id": callID,
"output": openAIResponsesMessageText(message), "output": output,
}) })
activeAssistantReasoningKey = "" activeAssistantReasoningKey = ""
continue continue
+60 -5
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"strings" "strings"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
@@ -215,6 +216,7 @@ func (projector *HistoryProjector) ProjectPromptReplay(conversation *Conversatio
if ok { if ok {
replayMessage.Name = toolName replayMessage.Name = toolName
replayMessage.Content = limitProjectedToolResultReplay(toolName, replayMessage.Content, payload.ResultText, true, historicalToolResult) replayMessage.Content = limitProjectedToolResultReplay(toolName, replayMessage.Content, payload.ResultText, true, historicalToolResult)
attachReadImageContentParts(&replayMessage, toolCall)
messages = append(messages, toModelMessage(replayMessage)) messages = append(messages, toModelMessage(replayMessage))
continue continue
} }
@@ -249,12 +251,13 @@ func (projector *HistoryProjector) ProjectPromptReplay(conversation *Conversatio
replayMessages[index].OpenAIResponsesReasoningSummary = append(json.RawMessage(nil), payload.ReasoningSummary...) replayMessages[index].OpenAIResponsesReasoningSummary = append(json.RawMessage(nil), payload.ReasoningSummary...)
applyPromptProviderMetadataToFirstToolCall(&replayMessages[index], payload.ProviderItemID, payload.ProviderCallID, payload.ProviderStatus) applyPromptProviderMetadataToFirstToolCall(&replayMessages[index], payload.ProviderItemID, payload.ProviderCallID, payload.ProviderStatus)
} }
for _, replay := range replayMessages { for index := range replayMessages {
if strings.TrimSpace(replay.Role) == "tool" { if strings.TrimSpace(replayMessages[index].Role) == "tool" {
toolName := firstNonEmpty(strings.TrimSpace(replay.Name), strings.TrimSpace(payload.ToolName)) toolName := firstNonEmpty(strings.TrimSpace(replayMessages[index].Name), strings.TrimSpace(payload.ToolName))
replay.Content = limitProjectedToolResultReplay(toolName, replay.Content, payload.ResultText, true, historicalToolResult) replayMessages[index].Content = limitProjectedToolResultReplay(toolName, replayMessages[index].Content, payload.ResultText, true, historicalToolResult)
attachReadImageContentParts(&replayMessages[index], toolCall)
} }
messages = append(messages, toModelMessage(replay)) messages = append(messages, toModelMessage(replayMessages[index]))
} }
continue continue
} }
@@ -305,6 +308,58 @@ func (projector *HistoryProjector) ProjectPromptReplay(conversation *Conversatio
return normalizeReplayMessageSequence(messages), nil return normalizeReplayMessageSequence(messages), nil
} }
func attachReadImageContentParts(message *promptengine.Message, toolCall *agentv1.ToolCall) {
if message == nil || toolCall == nil || strings.TrimSpace(message.Role) != "tool" {
return
}
readToolCall := toolCall.GetReadToolCall()
if readToolCall == nil {
return
}
success := readToolCall.GetResult().GetSuccess()
if success == nil {
return
}
data := success.GetData()
mimeType := supportedReadImageMIMEType(data)
if mimeType == "" {
return
}
path := firstNonEmpty(strings.TrimSpace(success.GetPath()), strings.TrimSpace(readToolCall.GetArgs().GetPath()))
summary := fmt.Sprintf("read image path=%q mime=%s bytes=%d", path, mimeType, len(data))
if fileSize := success.GetFileSize(); fileSize > 0 && uint64(fileSize) != uint64(len(data)) {
summary += fmt.Sprintf(" file_size=%d", fileSize)
}
if success.GetExceededLimit() {
summary += " truncated=true"
}
message.Content = summary
message.ContentParts = []promptengine.ContentPart{
{Type: "text", Text: summary},
{
Type: "image",
Image: &promptengine.ImageContent{
MIMEType: mimeType,
Path: path,
Data: append([]byte(nil), data...),
},
},
}
}
func supportedReadImageMIMEType(data []byte) string {
if len(data) == 0 {
return ""
}
mimeType := strings.ToLower(strings.TrimSpace(http.DetectContentType(data)))
switch mimeType {
case "image/png", "image/jpeg", "image/gif", "image/webp":
return mimeType
default:
return ""
}
}
func compactedPromptProjectionEntries(entries []HistoryEntry) []HistoryEntry { func compactedPromptProjectionEntries(entries []HistoryEntry) []HistoryEntry {
if len(entries) == 0 { if len(entries) == 0 {
return nil return nil