Files
Cyrene_v2/internal/runtime/multimodal/vision/adapter.go
T
AskaEth fa33c79e11
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(model,capacity,vision,io): per-persona 路由 + 本地三级(默认关) + L3 分类 + OCR 独立模型 + image-crop 工具(裁决 03.4.1/03.4.3/14.4.1/14.4.4/14.4.5)
- 03.4.1:per-persona 路由载体选 models.json(组内新增 personaModels/personaProviders + 校验 + PersonaRoute()),
  Router/RuntimeLLM 快照绑定、loop 流式路径按该 turn 的 persona 路由;主步用路由结果、降级步钉自己的模型
- 03.4.3:本地第三级降级链(LLM_LOCAL_*,Configured() 含 Enabled ⇒ **默认关**),warn 标注来源
- 14.4.4:capacity 接入 L3(分类器接口 + TTL 缓存 + 严格 JSON 解析),CAPACITY_L3_ENABLED **默认 false**
- 14.4.1:语义修正 —— group.multimodal 才是「主模型能否收图」(env 路径不再臆断),LLM_VISION_MODEL 只配视觉工具;
  vision/OCR 可各用不同模型;runtime 不再因有图抢路由(vision.Router 是唯一分层权威)
- 14.4.5:新增 image-crop 工具(工具数 5→6,策略默认 owner-only),schema 支持按区域裁切
- 组合根:wire_runtime 读 group.Multimodal;capacity 装配拆出 wire_capacity.go(守 500 行)
- 复核:`go build`/`go vet`/各包 `go test -count=1` 全绿;**6 条变异反证**先红后还原(含作者自查出的 2 条假阳性变异)
- 未落:按 provider 的 thinking 开关(需 openai_encode.go/openai.go 文件面)、persona awakening(需 loader 文件面)
2026-10-04 12:44:52 +08:00

242 lines
7.4 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// visionMaxTokens 限制 vision/OCR 响应的长度。
const visionMaxTokens = 512
// VisionAdapter 理解一张图像并返回对它的描述。它是
// 视觉工具背后的后端;真实提供方实现它,且不把图像
// 暴露给主模型上下文。
type VisionAdapter interface {
// Describe 返回 img 的描述。prompt 是可选的指令,
// 用于覆盖默认的「描述这张图」提问。
Describe(ctx context.Context, img ImageRef, prompt string) (string, error)
}
// OCRAdapter 从图像中抽取文字。它是 OCR 工具背后的后端。
type OCRAdapter interface {
// Extract 返回 img 中发现的文字。
Extract(ctx context.Context, img ImageRef) (string, error)
}
// OpenAICompatibleAdapter 通过调用 OpenAI 兼容的 /chat/completions
// endpoint(图像作为内容部件内嵌)同时实现 VisionAdapter 与 OCRAdapter。
// 它是开箱可用的默认实现;真实的
// 云端 vision/OCR 提供方暴露相同协议。
//
// 两个方向可以跑在同一个 endpoint 的两个模型上(裁决 14.4.1 的「独立视觉
// 模型 + OCR 模型」):Describe 用 model(LLM_VISION_MODEL),Extract 用
// ocrModel(LLM_OCR_MODEL),未配 OCR 模型时回落到同一个模型。
type OpenAICompatibleAdapter struct {
cfg config.LLMConfig
model string
ocrModel string
client *http.Client
log *slog.Logger
}
// OpenAIOption 用于定制 OpenAICompatibleAdapter。
type OpenAIOption func(*OpenAICompatibleAdapter)
// WithModel 覆盖适配器的模型(默认取 cfg.Model)。
func WithModel(m string) OpenAIOption {
return func(a *OpenAICompatibleAdapter) {
if m != "" {
a.model = m
}
}
}
// WithHTTPClient 设置请求所用的 *http.Client;nil 表示保持默认。
func WithHTTPClient(c *http.Client) OpenAIOption {
return func(a *OpenAICompatibleAdapter) {
if c != nil {
a.client = c
}
}
}
// WithLogger 设置失败时使用的日志器;nil 表示保持丢弃日志器。
func WithLogger(l *slog.Logger) OpenAIOption {
return func(a *OpenAICompatibleAdapter) {
if l != nil {
a.log = l
}
}
}
// WithOCRModel 覆盖 OCR 方向使用的模型(默认取 cfg.OCRModel,再回落
// 到 model)。
func WithOCRModel(m string) OpenAIOption {
return func(a *OpenAICompatibleAdapter) {
if m != "" {
a.ocrModel = m
}
}
}
// NewOpenAICompatible 用 cfg 构造一个适配器,未覆盖时使用
// cfg.Model。
func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAICompatibleAdapter {
a := &OpenAICompatibleAdapter{
cfg: cfg,
model: cfg.Model,
client: defaultHTTPClient(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
for _, o := range opts {
o(a)
}
if a.ocrModel == "" {
// LLM_OCR_MODEL 未配置时 OCR 与视觉共用同一个模型(向后兼容)。
a.ocrModel = cfg.OCRModel
}
if a.ocrModel == "" {
a.ocrModel = a.model
}
return a
}
// Describe 从底层 chat endpoint 返回图像描述。
func (a *OpenAICompatibleAdapter) Describe(ctx context.Context, img ImageRef, prompt string) (string, error) {
if prompt == "" {
prompt = "Describe this image in detail."
}
return a.ask(ctx, a.model, img, prompt)
}
// Extract 经同一 endpoint 返回图像中发现的文字;当配置了独立 OCR 模型
// (LLM_OCR_MODEL / WithOCRModel)时用它,否则与视觉模型相同。
func (a *OpenAICompatibleAdapter) Extract(ctx context.Context, img ImageRef) (string, error) {
return a.ask(ctx, a.ocrModel, img, "Extract all text visible in this image. Return only the text.")
}
// ask 解析图像、构造 chat 请求并返回助手文本。model 是本次请求使用的模型,
// 让两个方向能在同一 endpoint 上跑不同模型。
func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img ImageRef, prompt string) (string, error) {
if strings.TrimSpace(a.cfg.BaseURL) == "" {
return "", ErrNotConfigured
}
if strings.TrimSpace(model) == "" {
return "", ErrNotConfigured
}
if err := ctx.Err(); err != nil {
return "", err
}
imageURL, err := a.imageURL(ctx, img)
if err != nil {
return "", err
}
payload, err := json.Marshal(a.request(model, imageURL, prompt))
if err != nil {
return "", fmt.Errorf("vision: %w: marshal request: %v", ErrUpstream, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("vision: %w: build request: %v", ErrUpstream, err)
}
req.Header.Set("Content-Type", "application/json")
if a.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
}
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("vision: %w: %v", ErrUpstream, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxFetchedBody))
return "", fmt.Errorf("vision: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
return a.decode(resp.Body)
}
// imageURL 把 ImageRef 转换为线上格式期望的 image_url 取值:http(s)
// URL 原样透传,其余一律转为 base64 data: URI。
func (a *OpenAICompatibleAdapter) imageURL(ctx context.Context, img ImageRef) (string, error) {
if img.Source == SourceURL {
return img.Data, nil
}
mime, data, err := Resolve(ctx, img, a.client)
if err != nil {
return "", err
}
return DataURI(mime, data), nil
}
// openAIVisionRequest 是发往 /chat/completions 的 JSON 请求体。
type openAIVisionRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
}
type openAIMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type openAIPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *openAIImageURL `json:"image_url,omitempty"`
}
type openAIImageURL struct {
URL string `json:"url"`
}
type openAIVisionResponse struct {
Choices []openAIChoice `json:"choices"`
}
type openAIChoice struct {
Message openAIResponseMessage `json:"message"`
}
type openAIResponseMessage struct {
Content string `json:"content"`
}
// request 构造图像 + prompt 的线上请求体。
func (a *OpenAICompatibleAdapter) request(model, imageURL, prompt string) openAIVisionRequest {
return openAIVisionRequest{
Model: model,
Messages: []openAIMessage{
{Role: "user", Content: []openAIPart{
{Type: "text", Text: prompt},
{Type: "image_url", ImageURL: &openAIImageURL{URL: imageURL}},
}},
},
MaxTokens: visionMaxTokens,
}
}
// decode 从响应体中取出第一个 choice 的文本。
func (a *OpenAICompatibleAdapter) decode(r io.Reader) (string, error) {
var wire openAIVisionResponse
if err := json.NewDecoder(r).Decode(&wire); err != nil {
return "", fmt.Errorf("vision: %w: decode response: %v", ErrUpstream, err)
}
if len(wire.Choices) == 0 {
return "", fmt.Errorf("vision: %w: empty choices", ErrUpstream)
}
return wire.Choices[0].Message.Content, nil
}
// 接口断言:同一个具体适配器同时服务两个方向。
var _ VisionAdapter = (*OpenAICompatibleAdapter)(nil)
var _ OCRAdapter = (*OpenAICompatibleAdapter)(nil)