Files
Cyrene_v2/internal/runtime/multimodal/vision/resolver.go
T
AskaEth e0a6bca501 feat(browser): B3 截图→上下文闭环 + B4 面板声明页(Y4)
- B3:截图落盘后**自动**并入本轮视图(能收图 ⇒ 真 image 部件挂 user 消息;收不了 ⇒ vision/OCR 文字;
  大图先经 image-crop 裁切;取走即消费、每会话 ≤4 张、TTL 10min、跨会话隔离)
- B4:browser 面板**声明**(配置表单 + 运行状态/指纹自检/最近截图三张只读表 + 拍一张/重新自检)
  + 六个动作走 PLG-2 的通用动作端点(**不开新路由**)+ 保存事务(不可用则回滚 409)+ 热重载
- 读面**不启动浏览器**(计数型假 dialer 证明 0 次拨号);行数据不含路径/URL/内容
- 3 个变异(短路主路/行与列 key 脱钩/保存不回滚)
⚠️ **两行接线未落**(不在我文件面):wire_runtime.go 的 TransformContext + wire_frontend.go 的
   wireBrowserPanel —— 落地前 B3/B4 只是「组件就绪」,跑着的后端里不存在(Y4 已写盯梢用例)
2026-10-06 21:48:50 +08:00

163 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package vision
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// ModelInfoProvider 提供主模型的视觉能力,使解析器
// 能按 vision Router 的判定(design/06 §0)路由图像。它按
// 图像逐个调用;同一次解析中共享同一个 provider。
type ModelInfoProvider func() ModelInfo
// ToolInvoker 按名字与给定参数调用一个已注册的工具(视觉或 OCR
// 工具)。由 *tool.Dispatcher.Invoke 满足。
type ToolInvoker func(ctx context.Context, name string, args map[string]any) (any, error)
// MessageResolver 是 loop 的具体 ImageResolver。对用户消息中的
// 每个图像部件,它向 vision Router 询问如何路由:当主模型
// 支持视觉且有信心时,保留该图像部件让主模型看到
// 图像;否则调用 vision/OCR 工具,用其文字结果替换
// 图像部件,使纯文本主模型也能对图像推理(「先看图再
// 对话」)。它可安全并发使用。
type MessageResolver struct {
router *Router
info ModelInfoProvider
invoke ToolInvoker
cache *imageCache
}
// NewMessageResolver 构造一个 MessageResolver。router 决定
// 分层;info 报告主模型的视觉能力;invoke 运行一个已注册的 vision/OCR
// 工具。invoke 为 nil 时任何工具路由都会失败(此时图像被保留),
// 因此没有工具的解析器会降级到主模型路径。
//
// 解析器自带一个按内容哈希 memo 化的取回缓存:解析器在接线处构造一次并
// 长期复用,同一 URL 图因此只在首个回合真正取回一次(见 cache.go)。
func NewMessageResolver(router *Router, info ModelInfoProvider, invoke ToolInvoker) *MessageResolver {
return &MessageResolver{
router: router,
info: info,
invoke: invoke,
cache: newImageCache(),
}
}
// Resolve 改写 msg,使每个图像部件要么被保留(主模型能看到它),
// 要么被 vision/OCR 工具产出的文字描述替换。文本部件
// 按原顺序保留。它仅在图像无法路由或工具
// 调用失败时报错;此时调用方(loop)保留原始消息。
func (r *MessageResolver) Resolve(ctx context.Context, msg model.Message) (model.Message, error) {
if r.router == nil {
return msg, nil
}
out := make([]model.Content, 0, len(msg.Content))
for _, part := range msg.Content {
if part.Type != model.ContentTypeImage {
out = append(out, part)
continue
}
replacement, err := r.resolveImage(ctx, part.ImageURL)
if err != nil {
return model.Message{}, err
}
out = append(out, replacement)
}
return model.Message{Role: msg.Role, Content: out}, nil
}
// describePrompt 是走 vision 工具路由时给视觉模型的默认指令。它是**命名常量**
// 而不是散在两处的字面量:`resolveImage` 与 `ShotAttacher` 必须用同一句,
// 否则同一种图会因为「谁发的」而得到两种提问方式(结果不可比)。
const describePrompt = "Describe this image in detail."
// decision 是路由判定的唯一读取点:把「主模型能力 → Router」这一步收在一处,
// 使 resolveImage(用户附件)与 ShotAttacher(截图附件)不可能各自算一次而分叉。
func (r *MessageResolver) decision() Decision {
if r == nil || r.router == nil {
return Decision{Kind: KindUnsupported, Reason: "no vision router configured"}
}
target := ModelInfo{}
if r.info != nil {
target = r.info()
}
return r.router.Route(target, RouteRequest{})
}
// resolveImage 选出一条路由:要么保留图像(KindMain),
// 要么经 vision/OCR 工具把它折叠为文字描述。
func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) {
decision := r.decision()
if decision.Kind == KindMain {
// 支持视觉的主模型直接看到这张图;但 URL 与本地路径必须先由服务端
// 取回并内联为 data: URI——云 provider 取不到自部署的局域网地址
// (如 http://192.168.x.x:10942/api/v1/files/…),Anthropic 协议更是
// 只接受 base64 data: URI(model/anthropic_encode.go 会拒绝裸 URL)。
// 取回失败时返回结构化错误(带来源主机与原因),由 loop 记录一条
// 可诊断的 WARN 并保留原消息(该轮不因此中断);失败不进缓存,
// 下一回合会照常重试,因此不会「永久看不到图」。
src, err := inlineImageCached(ctx, r.cache, imageURL)
if err != nil {
return model.Content{}, err
}
return model.Content{Type: model.ContentTypeImage, ImageURL: src}, nil
}
return r.resolveToolRoute(ctx, imageURL, describePrompt, decision)
}
// resolveToolRoute 执行工具形态的那两条路由(vision / OCR):图像不进主模型
// 上下文,只把工具产出的文字折叠成一个文本部件。没有工具可用时如实报
// ErrUnsupportedSource(**绝不**悄悄把图像留给一个读不了图的主模型)。
func (r *MessageResolver) resolveToolRoute(
ctx context.Context, imageURL, prompt string, decision Decision,
) (model.Content, error) {
switch decision.Kind {
case KindVisionTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
"image": imageURL,
"prompt": prompt,
}, "description")
case KindOCRTool:
return r.invokeTool(ctx, decision.Tool, map[string]any{
"image": imageURL,
}, "text")
default:
return model.Content{}, fmt.Errorf("image route: %w: %s", ErrUnsupportedSource, decision.Reason)
}
}
// invokeTool 运行一个已注册的 vision/OCR 工具,并把其文字结果转成
// 文本内容部件。field 是结果 map 的键("description" 或 "text")。
func (r *MessageResolver) invokeTool(ctx context.Context, toolName string, args map[string]any, field string) (model.Content, error) {
if r.invoke == nil {
return model.Content{}, fmt.Errorf("%s: %w", toolName, ErrNotConfigured)
}
result, err := r.invoke(ctx, toolName, args)
if err != nil {
return model.Content{}, fmt.Errorf("image tool %s: %w", toolName, err)
}
return model.Content{Type: model.ContentTypeText, Text: descriptionFragment(fieldText(result, field))}, nil
}
// fieldText 从工具结果的 map 中取出字符串字段,兼容
// vision/OCR 工具返回的 map[string]any 形状。
func fieldText(result any, field string) string {
m, ok := result.(map[string]any)
if !ok {
return ""
}
s, _ := m[field].(string)
return s
}
// descriptionFragment 给工具产出的描述加上标注,使模型能看出它
// 指的是用户附上的那张图。
func descriptionFragment(desc string) string {
if desc == "" {
return "[图片内容] 无法识别该图片"
}
return "[图片内容] " + desc
}