- 只翻注释/文档字符串,代码零变化:go/scanner 词法流比对 21 文件 token+字面量全等,增删行数 1:1(467/467) - 刻意保留非散文英文:JSON Schema description、工具 Description() 返回值、adapter prompt、API 路径与命令示例、环境变量名 - 逐行确认代码前缀未变,未触碰其他模块
218 lines
6.4 KiB
Go
218 lines
6.4 KiB
Go
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 提供方暴露相同协议。
|
|
type OpenAICompatibleAdapter struct {
|
|
cfg config.LLMConfig
|
|
model 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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, img, prompt)
|
|
}
|
|
|
|
// Extract 经同一 endpoint 返回图像中发现的文字。
|
|
func (a *OpenAICompatibleAdapter) Extract(ctx context.Context, img ImageRef) (string, error) {
|
|
return a.ask(ctx, img, "Extract all text visible in this image. Return only the text.")
|
|
}
|
|
|
|
// ask 解析图像、构造 chat 请求并返回助手文本。
|
|
func (a *OpenAICompatibleAdapter) ask(ctx context.Context, img ImageRef, prompt string) (string, error) {
|
|
if strings.TrimSpace(a.cfg.BaseURL) == "" {
|
|
return "", ErrNotConfigured
|
|
}
|
|
if strings.TrimSpace(a.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(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(imageURL, prompt string) openAIVisionRequest {
|
|
return openAIVisionRequest{
|
|
Model: a.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)
|