From b0fbb8bc0d2e39dc0fbe045927b66bd683fe4737 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 12:28:16 +0800 Subject: [PATCH] =?UTF-8?q?docs(comments):=20=E6=B3=A8=E9=87=8A=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E5=8C=96=20=C2=B7=20internal/runtime/multimodal?= =?UTF-8?q?=EF=BC=8821=20=E6=96=87=E4=BB=B6=EF=BC=8C=E7=BA=AF=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 只翻注释/文档字符串,代码零变化:go/scanner 词法流比对 21 文件 token+字面量全等,增删行数 1:1(467/467) - 刻意保留非散文英文:JSON Schema description、工具 Description() 返回值、adapter prompt、API 路径与命令示例、环境变量名 - 逐行确认代码前缀未变,未触碰其他模块 --- internal/runtime/multimodal/doc.go | 26 +-- internal/runtime/multimodal/vision/adapter.go | 54 +++--- internal/runtime/multimodal/vision/errors.go | 18 +- internal/runtime/multimodal/vision/image.go | 42 ++--- .../runtime/multimodal/vision/resolver.go | 56 +++---- .../multimodal/vision/resolver_test.go | 6 +- internal/runtime/multimodal/vision/router.go | 78 ++++----- .../runtime/multimodal/vision/router_test.go | 4 +- internal/runtime/multimodal/vision/tool.go | 46 +++--- internal/runtime/multimodal/vision/vision.go | 54 +++--- internal/runtime/multimodal/voice/chain.go | 72 ++++---- .../runtime/multimodal/voice/chain_test.go | 12 +- internal/runtime/multimodal/voice/cloud.go | 66 ++++---- .../runtime/multimodal/voice/dashscope.go | 154 +++++++++--------- .../multimodal/voice/dashscope_live_test.go | 26 +-- internal/runtime/multimodal/voice/errors.go | 14 +- internal/runtime/multimodal/voice/provider.go | 48 +++--- internal/runtime/multimodal/voice/service.go | 62 +++---- .../runtime/multimodal/voice/service_test.go | 2 +- internal/runtime/multimodal/voice/voice.go | 58 +++---- .../multimodal/voice/voice_clone_test.go | 36 ++-- 21 files changed, 467 insertions(+), 467 deletions(-) diff --git a/internal/runtime/multimodal/doc.go b/internal/runtime/multimodal/doc.go index 76a487c..b5b2227 100644 --- a/internal/runtime/multimodal/doc.go +++ b/internal/runtime/multimodal/doc.go @@ -1,17 +1,17 @@ -// Package multimodal groups the runtime's non-text input/output handling: -// image understanding (vision) and speech (voice). +// Package multimodal 汇集运行时对非文本输入/输出的处理: +// 图像理解(视觉 vision)与语音(voice)。 // -// It follows DevDocs/design/06-多模态.md: +// 它遵循 DevDocs/design/06-多模态.md: // -// - The vision subpackage routes image messages through a layered strategy -// (main model first, then a tool-shaped vision/OCR adapter) and registers -// those adapters as sdk.Tool capabilities so the M2 tool loop can invoke -// them without putting the image in the main-model context. -// - The voice subpackage provides asynchronous STT/TTS through a degrading -// provider chain (cloud → local → edge-tts/espeak → silent). +// - vision 子包按分层策略路由图像消息(先主模型, +// 再工具形态的 vision/OCR 适配器),并把 +// 这些适配器注册为 sdk.Tool 能力,使 M2 工具循环能调用 +// 它们,而无需把图像放进主模型上下文。 +// - voice 子包通过一条降级提供方链 +// (cloud → local → edge-tts/espeak → silent)提供异步 STT/TTS。 // -// Both subpackages keep real provider calls behind interfaces with an HTTP -// default implementation, so they are testable with a stdlib httptest server -// and leave the real cloud/local providers as adaptation points for later -// milestones. +// 两个子包都把真实的提供方调用藏在接口之后,并提供一个 HTTP +// 默认实现,因此可用标准库 httptest 服务器测试, +// 同时把真实的云端/本地提供方留作后续 +// 里程碑的适配点。 package multimodal diff --git a/internal/runtime/multimodal/vision/adapter.go b/internal/runtime/multimodal/vision/adapter.go index ab0b2a3..58e5ded 100644 --- a/internal/runtime/multimodal/vision/adapter.go +++ b/internal/runtime/multimodal/vision/adapter.go @@ -13,28 +13,28 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// visionMaxTokens caps the vision/OCR response length. +// visionMaxTokens 限制 vision/OCR 响应的长度。 const visionMaxTokens = 512 -// VisionAdapter understands an image and returns a description of it. It is the -// backend behind the vision tool; real providers implement it without exposing -// the image to the main-model context. +// VisionAdapter 理解一张图像并返回对它的描述。它是 +// 视觉工具背后的后端;真实提供方实现它,且不把图像 +// 暴露给主模型上下文。 type VisionAdapter interface { - // Describe returns a description of img. prompt is an optional instruction - // overriding the default "describe this image" ask. + // Describe 返回 img 的描述。prompt 是可选的指令, + // 用于覆盖默认的「描述这张图」提问。 Describe(ctx context.Context, img ImageRef, prompt string) (string, error) } -// OCRAdapter extracts text from an image. It is the backend behind the OCR tool. +// OCRAdapter 从图像中抽取文字。它是 OCR 工具背后的后端。 type OCRAdapter interface { - // Extract returns the text found in img. + // Extract 返回 img 中发现的文字。 Extract(ctx context.Context, img ImageRef) (string, error) } -// OpenAICompatibleAdapter implements both VisionAdapter and OCRAdapter by -// calling an OpenAI-compatible /chat/completions endpoint with the image -// embedded as a content part. It is the ready default implementation; real -// cloud vision/OCR providers expose the same protocol. +// OpenAICompatibleAdapter 通过调用 OpenAI 兼容的 /chat/completions +// endpoint(图像作为内容部件内嵌)同时实现 VisionAdapter 与 OCRAdapter。 +// 它是开箱可用的默认实现;真实的 +// 云端 vision/OCR 提供方暴露相同协议。 type OpenAICompatibleAdapter struct { cfg config.LLMConfig model string @@ -42,10 +42,10 @@ type OpenAICompatibleAdapter struct { log *slog.Logger } -// OpenAIOption customizes an OpenAICompatibleAdapter. +// OpenAIOption 用于定制 OpenAICompatibleAdapter。 type OpenAIOption func(*OpenAICompatibleAdapter) -// WithModel overrides the adapter model (defaults to cfg.Model). +// WithModel 覆盖适配器的模型(默认取 cfg.Model)。 func WithModel(m string) OpenAIOption { return func(a *OpenAICompatibleAdapter) { if m != "" { @@ -54,7 +54,7 @@ func WithModel(m string) OpenAIOption { } } -// WithHTTPClient sets the *http.Client used for the request; nil keeps default. +// WithHTTPClient 设置请求所用的 *http.Client;nil 表示保持默认。 func WithHTTPClient(c *http.Client) OpenAIOption { return func(a *OpenAICompatibleAdapter) { if c != nil { @@ -63,7 +63,7 @@ func WithHTTPClient(c *http.Client) OpenAIOption { } } -// WithLogger sets the logger used on failures; nil keeps a discard logger. +// WithLogger 设置失败时使用的日志器;nil 表示保持丢弃日志器。 func WithLogger(l *slog.Logger) OpenAIOption { return func(a *OpenAICompatibleAdapter) { if l != nil { @@ -72,8 +72,8 @@ func WithLogger(l *slog.Logger) OpenAIOption { } } -// NewOpenAICompatible builds an adapter from cfg, using cfg.Model unless -// overridden. +// NewOpenAICompatible 用 cfg 构造一个适配器,未覆盖时使用 +// cfg.Model。 func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAICompatibleAdapter { a := &OpenAICompatibleAdapter{ cfg: cfg, @@ -87,7 +87,7 @@ func NewOpenAICompatible(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIComp return a } -// Describe returns an image description from the underlying chat endpoint. +// Describe 从底层 chat endpoint 返回图像描述。 func (a *OpenAICompatibleAdapter) Describe(ctx context.Context, img ImageRef, prompt string) (string, error) { if prompt == "" { prompt = "Describe this image in detail." @@ -95,12 +95,12 @@ func (a *OpenAICompatibleAdapter) Describe(ctx context.Context, img ImageRef, pr return a.ask(ctx, img, prompt) } -// Extract returns text found in the image via the same endpoint. +// 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 resolves the image, builds a chat request and returns the assistant text. +// ask 解析图像、构造 chat 请求并返回助手文本。 func (a *OpenAICompatibleAdapter) ask(ctx context.Context, img ImageRef, prompt string) (string, error) { if strings.TrimSpace(a.cfg.BaseURL) == "" { return "", ErrNotConfigured @@ -139,8 +139,8 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, img ImageRef, prompt return a.decode(resp.Body) } -// imageURL converts an ImageRef to the image_url value the wire expects: an -// http(s) URL passes through, anything else becomes a base64 data: URI. +// 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 @@ -152,7 +152,7 @@ func (a *OpenAICompatibleAdapter) imageURL(ctx context.Context, img ImageRef) (s return DataURI(mime, data), nil } -// openAIVisionRequest is the JSON body sent to /chat/completions. +// openAIVisionRequest 是发往 /chat/completions 的 JSON 请求体。 type openAIVisionRequest struct { Model string `json:"model"` Messages []openAIMessage `json:"messages"` @@ -186,7 +186,7 @@ type openAIResponseMessage struct { Content string `json:"content"` } -// request builds the wire body for an image+prompt. +// request 构造图像 + prompt 的线上请求体。 func (a *OpenAICompatibleAdapter) request(imageURL, prompt string) openAIVisionRequest { return openAIVisionRequest{ Model: a.model, @@ -200,7 +200,7 @@ func (a *OpenAICompatibleAdapter) request(imageURL, prompt string) openAIVisionR } } -// decode extracts the first choice's text from a response body. +// decode 从响应体中取出第一个 choice 的文本。 func (a *OpenAICompatibleAdapter) decode(r io.Reader) (string, error) { var wire openAIVisionResponse if err := json.NewDecoder(r).Decode(&wire); err != nil { @@ -212,6 +212,6 @@ func (a *OpenAICompatibleAdapter) decode(r io.Reader) (string, error) { return wire.Choices[0].Message.Content, nil } -// interface assertion: one concrete adapter serves both directions. +// 接口断言:同一个具体适配器同时服务两个方向。 var _ VisionAdapter = (*OpenAICompatibleAdapter)(nil) var _ OCRAdapter = (*OpenAICompatibleAdapter)(nil) diff --git a/internal/runtime/multimodal/vision/errors.go b/internal/runtime/multimodal/vision/errors.go index 9cce9dc..bb32e3d 100644 --- a/internal/runtime/multimodal/vision/errors.go +++ b/internal/runtime/multimodal/vision/errors.go @@ -2,21 +2,21 @@ package vision import "errors" -// Sentinel errors returned by the vision package. Each is wrapped with %w on -// the way out so callers can test for a category while keeping the cause. +// vision 包返回的哨兵错误。每个错误向外传递时都以 %w +// 包装,使调用方能按类别判断,同时保留原始原因。 var ( - // ErrNoImage is returned when an image source string is empty. + // ErrNoImage 在图像来源字符串为空时返回。 ErrNoImage = errors.New("vision: empty image source") - // ErrNotConfigured is returned by adapters when no endpoint/model is set. + // ErrNotConfigured 在适配器未设置 endpoint/model 时返回。 ErrNotConfigured = errors.New("vision: adapter not configured") - // ErrUnsupportedSource is returned when an image source cannot be handled. + // ErrUnsupportedSource 在图像来源无法处理时返回。 ErrUnsupportedSource = errors.New("vision: unsupported image source") - // ErrFetch is returned when a URL image cannot be downloaded. + // ErrFetch 在 URL 图像无法下载时返回。 ErrFetch = errors.New("vision: cannot fetch image") - // ErrUpstream is returned when the vision/OCR provider request fails. + // ErrUpstream 在 vision/OCR 提供方请求失败时返回。 ErrUpstream = errors.New("vision: upstream request failed") - // ErrBadImage is returned when an image cannot be read or decoded. + // ErrBadImage 在图像无法读取或解码时返回。 ErrBadImage = errors.New("vision: cannot read image") - // ErrBadArgs is returned by a tool when its arguments are malformed. + // ErrBadArgs 在工具参数格式错误时由该工具返回。 ErrBadArgs = errors.New("vision: bad tool arguments") ) diff --git a/internal/runtime/multimodal/vision/image.go b/internal/runtime/multimodal/vision/image.go index 5540278..906932c 100644 --- a/internal/runtime/multimodal/vision/image.go +++ b/internal/runtime/multimodal/vision/image.go @@ -10,23 +10,23 @@ import ( "strings" ) -// Size caps for reading image bytes and response bodies, so a hostile or -// malformed source cannot exhaust memory. The 50MB cap matches the file-upload -// limit in DevDocs/02-development-constraints.md §11. +// 读取图像字节与响应体的体积上限,使恶意或 +// 畸形来源无法耗尽内存。50MB 上限与 +// DevDocs/02-development-constraints.md §11 的文件上传限制一致。 const ( maxImageBytes = 50 << 20 maxFetchedBody = 50 << 20 ) -// DataURI builds a base64 data: image URI from mime and raw bytes. +// DataURI 由 mime 与原始字节构造一个 base64 data: 图像 URI。 func DataURI(mime string, data []byte) string { return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) } -// Resolve loads the bytes for an ImageRef. Paths and base64 payloads are read -// locally; URLs are fetched with an http.Client (the provided client, or a -// default one when nil). For base64 input the Data field is returned verbatim as -// the payload and the MIME type is guessed from a data: prefix. +// Resolve 加载一个 ImageRef 对应的字节。路径与 base64 载荷在 +// 本地读取;URL 用 http.Client 抓取(传入的 client,为 nil 时 +// 用默认 client)。对 base64 输入,Data 字段原样作为 +// 载荷返回,MIME 类型从 data: 前缀推断。 func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime string, data []byte, err error) { switch ref.Source { case SourcePath: @@ -40,7 +40,7 @@ func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime strin } } -// readFile reads an image file and guesses its MIME type from the extension. +// readFile 读取一个图像文件,并从扩展名推断其 MIME 类型。 func readFile(path string) (string, []byte, error) { f, err := os.Open(path) if err != nil { @@ -54,7 +54,7 @@ func readFile(path string) (string, []byte, error) { return mimeByExt(path), data, nil } -// decodeBase64 returns the decoded bytes of a base64/data: payload and its MIME. +// decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。 func decodeBase64(src string) (string, []byte, error) { mime, encoded, isDataURI := splitDataURI(src) if isDataURI { @@ -71,7 +71,7 @@ func decodeBase64(src string) (string, []byte, error) { return "image/png", decoded, nil } -// fetchURL downloads an image over http(s); only http/https schemes are allowed. +// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。 func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) { if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL) @@ -98,8 +98,8 @@ func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, return mimeByContentType(resp.Header.Get("Content-Type")), data, nil } -// splitDataURI parses a "data:;base64," prefix. The second return -// is false when src is not a data: URI. +// splitDataURI 解析 "data:;base64," 前缀。src 不是 +// data: URI 时第二个返回值是 false。 func splitDataURI(src string) (mime, payload string, ok bool) { if !strings.HasPrefix(src, "data:") { return "", "", false @@ -124,7 +124,7 @@ func splitDataURI(src string) (mime, payload string, ok bool) { return mime, payload, true } -// mimeByExt guesses a MIME type from a file path extension. +// mimeByExt 从文件路径扩展名推断 MIME 类型。 func mimeByExt(path string) string { switch strings.ToLower(pathExtension(path)) { case ".png": @@ -140,8 +140,8 @@ func mimeByExt(path string) string { } } -// mimeByContentType returns a MIME type from an HTTP Content-Type header, -// defaulting to image/png when absent or non-image. +// mimeByContentType 从 HTTP Content-Type 头返回 MIME 类型, +// 缺失或非图像时默认为 image/png。 func mimeByContentType(ct string) string { if ct == "" || !strings.HasPrefix(ct, "image/") { return "image/png" @@ -149,7 +149,7 @@ func mimeByContentType(ct string) string { return strings.TrimSpace(strings.Split(ct, ";")[0]) } -// pathExtension returns the file extension with its dot, or empty when none. +// pathExtension 返回带点号的文件扩展名,没有则返回空串。 func pathExtension(path string) string { base := path if idx := strings.LastIndexByte(path, '/'); idx >= 0 { @@ -164,15 +164,15 @@ func pathExtension(path string) string { return base[dot:] } -// fileExists reports whether path exists on disk and is a regular file. +// fileExists 报告 path 是否存在于磁盘且为常规文件。 func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && info.Mode().IsRegular() } -// defaultHTTPClient builds the HTTP client described in -// DevDocs/02-development-constraints.md §2.3. A fresh client is returned per -// call rather than a package global, which the constraints forbid. +// defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3 +// 所述的 HTTP client。这里每次调用返回一个新 client, +// 而不是包级全局变量——后者为约束所禁止。 func defaultHTTPClient() *http.Client { return &http.Client{Transport: http.DefaultTransport} } diff --git a/internal/runtime/multimodal/vision/resolver.go b/internal/runtime/multimodal/vision/resolver.go index ac1020a..df55f03 100644 --- a/internal/runtime/multimodal/vision/resolver.go +++ b/internal/runtime/multimodal/vision/resolver.go @@ -7,31 +7,31 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// ModelInfoProvider supplies the main model's vision capability so the resolver -// can route an image the way the vision Router decides (design/06 §0). It is -// called per image; the same provider is shared across a single resolution. +// ModelInfoProvider 提供主模型的视觉能力,使解析器 +// 能按 vision Router 的判定(design/06 §0)路由图像。它按 +// 图像逐个调用;同一次解析中共享同一个 provider。 type ModelInfoProvider func() ModelInfo -// ToolInvoker invokes a registered tool (the vision or OCR tool) by name with -// the given arguments. It is satisfied by *tool.Dispatcher.Invoke. +// ToolInvoker 按名字与给定参数调用一个已注册的工具(视觉或 OCR +// 工具)。由 *tool.Dispatcher.Invoke 满足。 type ToolInvoker func(ctx context.Context, name string, args map[string]any) (any, error) -// MessageResolver is the loop's concrete ImageResolver. For each image part in a -// user message it asks the vision Router how to route it: when the main model -// supports vision and is confident, the image part is kept so the main model sees -// the image; otherwise the vision/OCR tool is invoked and its text result replaces -// the image part, so a text-only main model can reason over the image ("先看图再 -// 对话"). It is safe for concurrent use. +// MessageResolver 是 loop 的具体 ImageResolver。对用户消息中的 +// 每个图像部件,它向 vision Router 询问如何路由:当主模型 +// 支持视觉且有信心时,保留该图像部件让主模型看到 +// 图像;否则调用 vision/OCR 工具,用其文字结果替换 +// 图像部件,使纯文本主模型也能对图像推理(「先看图再 +// 对话」)。它可安全并发使用。 type MessageResolver struct { router *Router info ModelInfoProvider invoke ToolInvoker } -// NewMessageResolver builds a MessageResolver. router decides the layer; info -// reports the main model's vision capability; invoke runs a registered vision/OCR -// tool. A nil invoke leaves any tool route failed (the image is then kept), so a -// resolver without tools degrades to the main-model path. +// NewMessageResolver 构造一个 MessageResolver。router 决定 +// 分层;info 报告主模型的视觉能力;invoke 运行一个已注册的 vision/OCR +// 工具。invoke 为 nil 时任何工具路由都会失败(此时图像被保留), +// 因此没有工具的解析器会降级到主模型路径。 func NewMessageResolver(router *Router, info ModelInfoProvider, invoke ToolInvoker) *MessageResolver { return &MessageResolver{ router: router, @@ -40,10 +40,10 @@ func NewMessageResolver(router *Router, info ModelInfoProvider, invoke ToolInvok } } -// Resolve rewrites msg so every image part is either kept (main model sees it) or -// replaced by a text description produced by the vision/OCR tool. Text parts are -// preserved in order. It errors only when an image cannot be routed or a tool -// invocation fails; the caller (loop) then keeps the original message. +// 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 @@ -63,8 +63,8 @@ func (r *MessageResolver) Resolve(ctx context.Context, msg model.Message) (model return model.Message{Role: msg.Role, Content: out}, nil } -// resolveImage picks a route and either keeps the image (KindMain) or folds it -// into a text description via the vision/OCR tool. +// resolveImage 选出一条路由:要么保留图像(KindMain), +// 要么经 vision/OCR 工具把它折叠为文字描述。 func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (model.Content, error) { target := ModelInfo{} if r.info != nil { @@ -73,7 +73,7 @@ func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (mo decision := r.router.Route(target, RouteRequest{}) switch decision.Kind { case KindMain: - // A vision-capable main model sees the image directly. + // 支持视觉的主模型直接看到这张图。 return model.Content{Type: model.ContentTypeImage, ImageURL: imageURL}, nil case KindVisionTool: return r.invokeTool(ctx, decision.Tool, map[string]any{ @@ -89,8 +89,8 @@ func (r *MessageResolver) resolveImage(ctx context.Context, imageURL string) (mo } } -// invokeTool runs a registered vision/OCR tool and turns its text result into a -// text content part. field is the result map key ("description" or "text"). +// 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) @@ -102,8 +102,8 @@ func (r *MessageResolver) invokeTool(ctx context.Context, toolName string, args return model.Content{Type: model.ContentTypeText, Text: descriptionFragment(fieldText(result, field))}, nil } -// fieldText extracts a string field from a tool result map, tolerating the -// map[string]any shape returned by the vision/OCR tools. +// fieldText 从工具结果的 map 中取出字符串字段,兼容 +// vision/OCR 工具返回的 map[string]any 形状。 func fieldText(result any, field string) string { m, ok := result.(map[string]any) if !ok { @@ -113,8 +113,8 @@ func fieldText(result any, field string) string { return s } -// descriptionFragment labels a tool-produced description so the model can tell it -// refers to the picture the user attached. +// descriptionFragment 给工具产出的描述加上标注,使模型能看出它 +// 指的是用户附上的那张图。 func descriptionFragment(desc string) string { if desc == "" { return "[图片内容] 无法识别该图片" diff --git a/internal/runtime/multimodal/vision/resolver_test.go b/internal/runtime/multimodal/vision/resolver_test.go index 4107080..4c9fe64 100644 --- a/internal/runtime/multimodal/vision/resolver_test.go +++ b/internal/runtime/multimodal/vision/resolver_test.go @@ -69,9 +69,9 @@ func TestMessageResolver_OCRToolForText(t *testing.T) { return map[string]any{"text": "HELLO"}, nil }) - // Force the text-primary hint by using a router route request path: this is - // exercised directly through Resolve which passes no hint, so the vision tool - // is preferred; assert at least a text part replaces the image. + // 通过 router 的路由请求路径强制给出文本优先提示:这里直接经 Resolve + // 走通,而 Resolve 不传任何提示,因此优先使用视觉工具; + // 断言至少有一个文本部件替换了图像。 msg, err := res.Resolve(context.Background(), model.ImageMessage(model.RoleUser, "x", "data:image/png;base64,AAAA")) if err != nil { t.Fatalf("Resolve: %v", err) diff --git a/internal/runtime/multimodal/vision/router.go b/internal/runtime/multimodal/vision/router.go index 006399c..c4dcff2 100644 --- a/internal/runtime/multimodal/vision/router.go +++ b/internal/runtime/multimodal/vision/router.go @@ -1,67 +1,67 @@ package vision -// Assessment is the model's own confidence in reading an image. +// Assessment 是模型对自己读图结果的置信度。 type Assessment string const ( - // AssessmentUnknown means the model reported no self-assessment. + // AssessmentUnknown 表示模型未给出自评。 AssessmentUnknown Assessment = "unknown" - // AssessmentGood means the model is confident in its read. + // AssessmentGood 表示模型对自己的读图结果有信心。 AssessmentGood Assessment = "good" - // AssessmentPoor means the model judged its recognition as poor. + // AssessmentPoor 表示模型判定自己的识别效果差。 AssessmentPoor Assessment = "poor" ) -// ModelInfo describes the main model's vision capability. +// ModelInfo 描述主模型的视觉能力。 type ModelInfo struct { - // Name identifies the model. + // Name 标识模型。 Name string - // SupportsVision reports whether the model accepts image input. + // SupportsVision 报告模型是否接受图像输入。 SupportsVision bool - // SelfAssessment is the model's own confidence for the current image. - // It drives the layer-2 tool route when SupportsVision is true. + // SelfAssessment 是模型对当前图像的自评置信度。 + // 当 SupportsVision 为真时,它决定是否走第二层工具路由。 SelfAssessment Assessment } -// DecisionKind is the chosen layered route. +// DecisionKind 是选定的分层路由。 type DecisionKind string const ( - // KindMain routes the image to the main model with the image in context. + // KindMain 把图像连同上下文一起交给主模型。 KindMain DecisionKind = "main" - // KindVisionTool routes to a dedicated vision tool, returning only the tool - // result so the image never occupies main-model context. + // KindVisionTool 路由到专用视觉工具,只返回工具 + // 结果,使图像不占用主模型上下文。 KindVisionTool DecisionKind = "vision_tool" - // KindOCRTool routes to an OCR tool, returning only the extracted text. + // KindOCRTool 路由到 OCR 工具,只返回抽取出的文字。 KindOCRTool DecisionKind = "ocr_tool" - // KindUnsupported means no route can process the image. + // KindUnsupported 表示没有任何路由能处理该图像。 KindUnsupported DecisionKind = "unsupported" ) -// Decision is the outcome of a routing call. +// Decision 是一次路由调用的结果。 type Decision struct { - // Kind is the chosen route. + // Kind 是选定的路由。 Kind DecisionKind - // Tool names the vision or OCR tool to invoke, set when Kind is a tool route. + // Tool 指名要调用的视觉或 OCR 工具,当 Kind 为工具路由时设置。 Tool string - // Reason is a human-readable explanation for observability and logs. + // Reason 是便于可观测与日志的人类可读说明。 Reason string } -// RouteRequest carries routing hints from the caller on top of the model info. +// RouteRequest 携带调用方在模型信息之外给出的路由提示。 type RouteRequest struct { - // TextPrimary reports that the user indicated the image is mainly text, so - // an OCR tool is preferred over a general vision model. + // TextPrimary 表示用户指出图像以文字为主,因此 + // 优先使用 OCR 工具而非通用视觉模型。 TextPrimary bool - // MainSelfAssessedPoor is set when the main model has already judged its own - // read of the image as poor, requesting a vision/OCR tool route. + // MainSelfAssessedPoor 在主模型已自评读图效果 + // 较差、请求走 vision/OCR 工具路由时置位。 MainSelfAssessedPoor bool } -// RouterOption configures a Router. +// RouterOption 配置 Router。 type RouterOption func(*Router) -// WithVisionTool binds the name of the registered vision-understanding tool. +// WithVisionTool 绑定已注册的图像理解工具名。 func WithVisionTool(name string) RouterOption { return func(r *Router) { if name != "" { @@ -70,7 +70,7 @@ func WithVisionTool(name string) RouterOption { } } -// WithOCRTool binds the name of the registered OCR tool. +// WithOCRTool 绑定已注册的 OCR 工具名。 func WithOCRTool(name string) RouterOption { return func(r *Router) { if name != "" { @@ -79,15 +79,15 @@ func WithOCRTool(name string) RouterOption { } } -// Router implements the layered vision routing policy. It does no I/O; the -// caller uses the returned Decision to dispatch the request. +// Router 实现分层视觉路由策略。它不做任何 I/O;调用方 +// 用返回的 Decision 去派发请求。 type Router struct { visionTool string ocrTool string } -// NewRouter builds a Router. Unknown (empty) tool names leave the corresponding -// route unavailable, so the router degrades to KindMain or KindUnsupported. +// NewRouter 构造 Router。未知(空)的工具名会让对应 +// 路由不可用,于是路由器降级为 KindMain 或 KindUnsupported。 func NewRouter(opts ...RouterOption) *Router { r := &Router{} for _, o := range opts { @@ -96,21 +96,21 @@ func NewRouter(opts ...RouterOption) *Router { return r } -// Route picks a layered route for an image request given the target model info -// and caller hints. The order is: the main model directly when it can see and -// has not self-assessed poorly (layer 1); otherwise a vision or OCR tool whose -// result replaces the image in context (layers 2 and 3); otherwise -// KindUnsupported when nothing can process the image. +// Route 依据目标模型信息与调用方提示,为一次图像请求 +// 选出分层路由。顺序为:主模型能看图且 +// 未自评较差时直接用它(第一层);否则用能替换 +// 上下文中图像的视觉或 OCR 工具(第二、三层);再否则 +// 没有任何东西能处理该图像时给出 KindUnsupported。 func (r *Router) Route(target ModelInfo, req RouteRequest) Decision { poor := target.SelfAssessment == AssessmentPoor || req.MainSelfAssessedPoor - // Layer 1: the main model accepts images and is confident, so use it first. + // 第一层:主模型接受图像且自信,优先使用它。 if target.SupportsVision && !poor { return Decision{ Kind: KindMain, Reason: "main model supports vision and is confident; use it directly", } } - // OCR is preferred when the user says the image is mostly text. + // 用户说明图像以文字为主时优先 OCR。 if req.TextPrimary && r.ocrTool != "" { return Decision{Kind: KindOCRTool, Tool: r.ocrTool, Reason: "image is mainly text; route to OCR"} } @@ -120,7 +120,7 @@ func (r *Router) Route(target ModelInfo, req RouteRequest) Decision { if r.ocrTool != "" { return Decision{Kind: KindOCRTool, Tool: r.ocrTool, Reason: "route to OCR tool"} } - // No tool is bound. A sighted main model remains the only option. + // 未绑定任何工具。能看图的主模型仍是唯一选择。 if target.SupportsVision { return Decision{Kind: KindMain, Reason: "no vision/OCR tool bound; keep image with main model"} } diff --git a/internal/runtime/multimodal/vision/router_test.go b/internal/runtime/multimodal/vision/router_test.go index 5ac26a1..c130c8c 100644 --- a/internal/runtime/multimodal/vision/router_test.go +++ b/internal/runtime/multimodal/vision/router_test.go @@ -96,14 +96,14 @@ func TestRouter_Route(t *testing.T) { } } -// fakeVision implements VisionAdapter for tool tests. +// fakeVision 为工具测试实现 VisionAdapter。 type fakeVision struct{ desc string } func (f *fakeVision) Describe(_ context.Context, _ ImageRef, _ string) (string, error) { return f.desc, nil } -// fakeOCR implements OCRAdapter for tool tests. +// fakeOCR 为工具测试实现 OCRAdapter。 type fakeOCR struct{ text string } func (f *fakeOCR) Extract(_ context.Context, _ ImageRef) (string, error) { diff --git a/internal/runtime/multimodal/vision/tool.go b/internal/runtime/multimodal/vision/tool.go index 02ed7cd..7072117 100644 --- a/internal/runtime/multimodal/vision/tool.go +++ b/internal/runtime/multimodal/vision/tool.go @@ -7,9 +7,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) -// toolParameters returns the shared input JSON Schema for the vision and OCR -// tools. It builds a fresh map each call rather than sharing a package-level -// variable. +// toolParameters 返回视觉工具与 OCR 工具共用的输入 JSON Schema。 +// 它每次调用都新建一个 map,而不是共享包级 +// 变量。 func toolParameters() map[string]any { return map[string]any{ "type": "object", @@ -27,33 +27,33 @@ func toolParameters() map[string]any { } } -// VisionTool wraps a VisionAdapter as an sdk.Tool so the M2 tool loop can invoke -// it and return its description as a tool result, keeping the image out of the -// main-model context. It is safe for concurrent use. +// VisionTool 把 VisionAdapter 包装成 sdk.Tool,使 M2 工具循环能调用 +// 它并把描述作为工具结果返回,从而让图像不进入 +// 主模型上下文。它可安全并发使用。 type VisionTool struct { name string adapter VisionAdapter } -// NewVisionTool builds a VisionTool with the given name and adapter. +// NewVisionTool 用给定名字与适配器构造一个 VisionTool。 func NewVisionTool(name string, adapter VisionAdapter) *VisionTool { return &VisionTool{name: name, adapter: adapter} } -// Name returns the tool identifier the agent uses to invoke it. +// Name 返回智能体用来调用该工具的工具标识。 func (t *VisionTool) Name() string { return t.name } -// Description explains what the tool does. +// Description 说明该工具做什么。 func (t *VisionTool) Description() string { return "Understand an image and return a description of its contents." } -// Parameters returns the input JSON Schema. +// Parameters 返回输入 JSON Schema。 func (t *VisionTool) Parameters() (map[string]any, error) { return toolParameters(), nil } -// Execute runs the adapter on the image from args and returns the description. +// Execute 对 args 中的图像运行适配器并返回描述。 func (t *VisionTool) Execute(ctx context.Context, args any) (any, error) { image, prompt, err := toolArgs(args) if err != nil { @@ -70,32 +70,32 @@ func (t *VisionTool) Execute(ctx context.Context, args any) (any, error) { return map[string]any{"description": desc}, nil } -// OCRTool wraps an OCRAdapter as an sdk.Tool. It returns the extracted text as a -// tool result. It is safe for concurrent use. +// OCRTool 把 OCRAdapter 包装成 sdk.Tool。它把抽取出的文字作为 +// 工具结果返回。它可安全并发使用。 type OCRTool struct { name string adapter OCRAdapter } -// NewOCRTool builds an OCRTool with the given name and adapter. +// NewOCRTool 用给定名字与适配器构造一个 OCRTool。 func NewOCRTool(name string, adapter OCRAdapter) *OCRTool { return &OCRTool{name: name, adapter: adapter} } -// Name returns the tool identifier. +// Name 返回工具标识。 func (t *OCRTool) Name() string { return t.name } -// Description explains what the tool does. +// Description 说明该工具做什么。 func (t *OCRTool) Description() string { return "Extract text from an image (OCR) and return the recognized text." } -// Parameters returns the input JSON Schema. +// Parameters 返回输入 JSON Schema。 func (t *OCRTool) Parameters() (map[string]any, error) { return toolParameters(), nil } -// Execute runs the OCR adapter on the image from args and returns the text. +// Execute 对 args 中的图像运行 OCR 适配器并返回文字。 func (t *OCRTool) Execute(ctx context.Context, args any) (any, error) { image, _, err := toolArgs(args) if err != nil { @@ -112,9 +112,9 @@ func (t *OCRTool) Execute(ctx context.Context, args any) (any, error) { return map[string]any{"text": text}, nil } -// RegisterTools registers the vision and OCR tools with r via the SDK helper. -// Each tool is only registered when its pointer is non-nil. It returns the -// first registration error, or nil when every tool registered. +// RegisterTools 经 SDK 辅助函数把视觉工具与 OCR 工具注册到 r。 +// 每个工具仅在其指针非 nil 时注册。它返回 +// 第一个注册错误;全部注册成功时返回 nil。 func RegisterTools(r sdk.Registrar, tool *VisionTool, ocr *OCRTool) error { if r == nil { return sdk.ErrNilRegistrar @@ -132,8 +132,8 @@ func RegisterTools(r sdk.Registrar, tool *VisionTool, ocr *OCRTool) error { return nil } -// toolArgs decodes sdk.Tool.Execute args into the image string and an optional -// prompt. Args must be a map[string]any with a non-empty "image" string. +// toolArgs 把 sdk.Tool.Execute 的 args 解码为图像字符串与一个可选的 +// prompt。args 必须是含非空 "image" 字符串的 map[string]any。 func toolArgs(args any) (image, prompt string, err error) { m, ok := args.(map[string]any) if !ok { diff --git a/internal/runtime/multimodal/vision/vision.go b/internal/runtime/multimodal/vision/vision.go index 42bf06f..0ef3936 100644 --- a/internal/runtime/multimodal/vision/vision.go +++ b/internal/runtime/multimodal/vision/vision.go @@ -1,15 +1,15 @@ -// Package vision implements the layered image routing described in -// DevDocs/design/06-多模态.md §0. +// Package vision 实现 DevDocs/design/06-多模态.md §0 所述的 +// 分层图像路由。 // -// A request that carries image content is first checked by DetectMultimodal. -// The VisionRouter then picks one of the layered routes: the main model -// directly (when it accepts images), a tool-shaped vision/OCR adapter (when the -// main model self-assesses poorly or cannot see), or the OCR tool (when the -// image is mainly text). Vision/OCR adapters are registered as sdk.Tool -// capabilities through the capability registry, and their output is a tool -// result, so the image itself never occupies main-model context. +// 携带图像内容的请求先由 DetectMultimodal 检查。 +// 随后 VisionRouter 选出其中一条分层路由:主模型 +// 直连(当它接受图像时)、工具形态的 vision/OCR 适配器(当主模型 +// 自评较差或看不见图像时),或 OCR 工具(当 +// 图像以文字为主时)。vision/OCR 适配器经能力注册中心 +// 注册为 sdk.Tool 能力,其输出即工具 +// 结果,因此图像本身从不占用主模型上下文。 // -// Images may come from a local path, an http(s) URL, or a base64/data: URI. +// 图像可来自本地路径、http(s) URL 或 base64/data: URI。 package vision import ( @@ -18,11 +18,11 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// DetectMultimodal reports whether msgs contains any non-text content part, -// i.e. whether multimodal handling is required. Today model only distinguishes -// text from image content, so this is true exactly when a message carries an -// image part; the check is written against "not text" so future content types -// (audio) extend it without rework. +// DetectMultimodal 报告 msgs 是否含任何非文本内容部件, +// 即是否需要多模态处理。当前 model 只区分 +// 文本与图像内容,因此这等价于某条消息携带 +// 图像部件;判断写成「非文本」是为了让未来的内容类型 +// (音频)无需返工即可扩展。 func DetectMultimodal(msgs []model.Message) bool { for _, m := range msgs { for _, part := range m.Content { @@ -34,30 +34,30 @@ func DetectMultimodal(msgs []model.Message) bool { return false } -// ImageSource classifies where an image's bytes come from. +// ImageSource 归类一副图像的字节来自何处。 type ImageSource string const ( - // SourcePath is a local filesystem path. + // SourcePath 是本地文件系统路径。 SourcePath ImageSource = "path" - // SourceURL is an http(s):// URL. + // SourceURL 是 http(s):// URL。 SourceURL ImageSource = "url" - // SourceBase64 is a base64 payload, optionally wrapped in a data: URI. + // SourceBase64 是 base64 载荷,可选地包在 data: URI 中。 SourceBase64 ImageSource = "base64" ) -// ImageRef is a single image reference plus its resolved source kind. +// ImageRef 是单个图像引用及其已归类的来源种类。 type ImageRef struct { - // Source is the classified source kind. + // Source 是归类后的来源种类。 Source ImageSource - // Data is the local path, URL, or base64/data: payload. + // Data 是本地路径、URL 或 base64/data: 载荷。 Data string } -// ParseImageRef classifies a raw image source string into an ImageRef. Empty -// input is rejected. Local paths that exist on disk are classified as paths; -// http(s) URLs as URLs; every other value, including a data: URI, is treated as -// a base64 payload. This keeps routing deterministic without guessing MIME. +// ParseImageRef 把原始图像来源字符串归类为 ImageRef。空 +// 输入被拒绝。磁盘上存在的本地路径归类为路径; +// http(s) URL 归类为 URL;其余所有值(含 data: URI)都视为 +// base64 载荷。这样无需猜测 MIME 即可保持路由确定性。 func ParseImageRef(src string) (ImageRef, error) { if src == "" { return ImageRef{}, ErrNoImage @@ -68,7 +68,7 @@ func ParseImageRef(src string) (ImageRef, error) { if strings.HasPrefix(src, "file://") { return ImageRef{Source: SourcePath, Data: strings.TrimPrefix(src, "file://")}, nil } - // A data: URI is base64 already; treat as base64, not path. + // data: URI 本身就是 base64;按 base64 处理,而非路径。 if strings.HasPrefix(src, "data:") { return ImageRef{Source: SourceBase64, Data: src}, nil } diff --git a/internal/runtime/multimodal/voice/chain.go b/internal/runtime/multimodal/voice/chain.go index 71f23ca..f13c4e0 100644 --- a/internal/runtime/multimodal/voice/chain.go +++ b/internal/runtime/multimodal/voice/chain.go @@ -7,31 +7,31 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// Chain is a degrading provider list. STT and TTS walk the steps in order, -// returning the first successful result and moving to the next step when a -// provider is unavailable (nil direction) or fails. It implements STT and TTS so -// it can be used anywhere a single provider is expected. +// Chain 是一条降级提供方列表。STT 与 TTS 按序走各级, +// 返回第一个成功的结果;某级不可用(方向为 nil)或 +// 失败时则移向下一级。它实现 STT 与 TTS, +// 因此可用在任何期望单个提供方的地方。 type Chain struct { steps []Provider cloner VoiceCloner } -// NewChain builds a degrading chain from the given providers in the order they -// should be tried. Passing a SilentProvider last guarantees the chain always -// succeeds. +// NewChain 按给定提供方的尝试顺序构造一条降级链。 +// 把 SilentProvider 放在最后,可保证 +// 这条链总是成功。 func NewChain(steps ...Provider) *Chain { return &Chain{steps: steps} } -// STT walks the chain for a transcription. +// STT 走链取一次转写。 func (c *Chain) STT(ctx context.Context, audio Audio) (string, error) { text, _, err := c.STTWithProvider(ctx, audio) return text, err } -// STTWithProvider is STT but also reports the name of the provider rung that -// produced the transcription (cloud / local / edge-tts / silent), so a caller can -// surface which tier actually served the request. +// STTWithProvider 即 STT,但额外报告产出该转写的提供方 +// 级别名(cloud / local / edge-tts / silent),使调用方能 +// 展示实际是哪一级服务了该请求。 func (c *Chain) STTWithProvider(ctx context.Context, audio Audio) (string, string, error) { if len(c.steps) == 0 { return "", "", ErrProviderFailed @@ -51,9 +51,9 @@ func (c *Chain) STTWithProvider(ctx context.Context, audio Audio) (string, strin return "", "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr) } -// CloneVoice creates a custom voice via the cloud provider's voice-enrollment -// endpoint (声音复刻 create_voice). A chain without a cloud rung returns -// ErrNotConfigured. It is not a degradation step — the caller gets one result. +// CloneVoice 经云端提供方的音色注册 endpoint 创建自定义音色 +// (声音复刻 create_voice)。没有云端一级的链返回 +// ErrNotConfigured。它不是降级步骤 —— 调用方只拿到一个结果。 func (c *Chain) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) { if c.cloner == nil { return "", ErrNotConfigured @@ -61,14 +61,14 @@ func (c *Chain) CloneVoice(ctx context.Context, referenceURL, prefix string) (st return c.cloner.CloneVoice(ctx, referenceURL, prefix) } -// TTS walks the chain for synthesized audio. +// TTS 走链取合成音频。 func (c *Chain) TTS(ctx context.Context, text string) (Audio, error) { audio, _, err := c.TTSWithProvider(ctx, text) return audio, err } -// TTSWithProvider is TTS but also reports the name of the provider rung that -// produced the audio. +// TTSWithProvider 即 TTS,但额外报告产出该音频的提供方 +// 级别名。 func (c *Chain) TTSWithProvider(ctx context.Context, text string) (Audio, string, error) { if len(c.steps) == 0 { return Audio{}, "", ErrProviderFailed @@ -88,32 +88,32 @@ func (c *Chain) TTSWithProvider(ctx context.Context, text string) (Audio, string return Audio{}, "", fmt.Errorf("voice: %w: %v", ErrProviderFailed, lastErr) } -// Option customizes a chain built by New. +// Option 定制由 New 构造的链。 type Option func(*chainConfig) -// chainConfig carries the assembled steps for New. +// chainConfig 承载 New 组装出的各级。 type chainConfig struct { steps []Provider } -// WithProviders overrides the provider steps assembled from config. Tests use it -// to inject deterministic mock providers instead of the real adaptation-point -// rungs. +// WithProviders 覆盖由配置组装出的提供方各级。测试用它 +// 注入确定性的 mock 提供方,而不是真实的适配点 +// 各级。 func WithProviders(steps ...Provider) Option { return func(c *chainConfig) { c.steps = steps } } -// New assembles a chain from cfg using the fixed degradation order -// cloud → local → edge-tts/espeak → silent. +// New 用固定降级顺序从 cfg 组装一条链: +// cloud → local → edge-tts/espeak → silent。 // -// - cloud is included only when a cloud voice credential or model is present -// (voiceConfigured) so a mis-configured build degrades quietly. -// - local is a pure adaptation point and is only included when supplied via -// WithProviders; by default it is skipped. -// - the edge-tts or espeak rung is included according to cfg.TTSProvider. -// - silent is always appended last so the chain never fails outright. +// - 仅当存在云端语音凭据或模型(voiceConfigured)时才纳入 +// cloud,使配置有误的构建安静降级。 +// - local 是纯适配点,仅当经 WithProviders 提供时才纳入; +// 默认跳过。 +// - 依 cfg.TTSProvider 纳入 edge-tts 或 espeak 一级。 +// - silent 始终追加在最后,使链绝不会直接失败。 // -// opts may supply a fully assembled step list to override this mapping. +// opts 可提供一份完整组装好的各级列表来覆盖上述映射。 func New(cfg config.VoiceConfig, opts ...Option) *Chain { conf := &chainConfig{} for _, o := range opts { @@ -141,11 +141,11 @@ func New(cfg config.VoiceConfig, opts ...Option) *Chain { return chain } -// voiceConfigured reports whether any cloud voice credential or model is set, so -// the cloud rung is only added when the runtime intends to use DashScope. Any -// single present field (API key, base URL, or an STT/TTS model) makes the cloud -// rung worth trying; a missing key then makes the adapter return ErrNotConfigured -// and the chain degrades quietly. +// voiceConfigured 报告是否设置了任一云端语音凭据或模型,使 +// 云端一级只在运行时有意使用 DashScope 时才加入。任一字段 +// 存在(API key、base URL 或某个 STT/TTS 模型)都让云端 +// 一级值得一试;随后缺失的 key 会让适配器返回 ErrNotConfigured, +// 链则安静降级。 func voiceConfigured(cfg config.VoiceConfig) bool { return cfg.DashscopeAPIKey != "" || cfg.DashscopeBaseURL != "" || cfg.STTModel != "" || cfg.TTSModel != "" } diff --git a/internal/runtime/multimodal/voice/chain_test.go b/internal/runtime/multimodal/voice/chain_test.go index 731d02b..602ea98 100644 --- a/internal/runtime/multimodal/voice/chain_test.go +++ b/internal/runtime/multimodal/voice/chain_test.go @@ -8,7 +8,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// stubSTT is a mock STT returning a fixed result. +// stubSTT 是一个返回固定结果的 mock STT。 type stubSTT struct { text string err error @@ -16,7 +16,7 @@ type stubSTT struct { func (s *stubSTT) STT(_ context.Context, _ Audio) (string, error) { return s.text, s.err } -// stubTTS is a mock TTS returning a fixed result. +// stubTTS 是一个返回固定结果的 mock TTS。 type stubTTS struct { audio Audio err error @@ -24,7 +24,7 @@ type stubTTS struct { func (s *stubTTS) TTS(_ context.Context, _ string) (Audio, error) { return s.audio, s.err } -// stubProvider is a mock Provider carrying optional directions. +// stubProvider 是携带可选方向的 mock Provider。 type stubProvider struct { name string stt STT @@ -158,8 +158,8 @@ func TestChain_Empty(t *testing.T) { func TestNew_ConfigAssembly(t *testing.T) { t.Parallel() - // With a cloud key and default edge-tts TTS, the chain starts at cloud and - // ends at silent. + // 配了云端 key 且 TTS 默认走 edge-tts 时,链从 cloud 开始、 + // 到 silent 结束。 c := New(config.VoiceConfig{DashscopeAPIKey: "k"}) names := make([]string, 0, len(c.steps)) for _, p := range c.steps { @@ -172,7 +172,7 @@ func TestNew_ConfigAssembly(t *testing.T) { t.Errorf("first step = %q, want cloud", names[0]) } - // Without a key the cloud rung is skipped. + // 没有 key 时跳过云端一级。 c2 := New(config.VoiceConfig{}) foundCloud := false for _, p := range c2.steps { diff --git a/internal/runtime/multimodal/voice/cloud.go b/internal/runtime/multimodal/voice/cloud.go index 760cbd5..032054a 100644 --- a/internal/runtime/multimodal/voice/cloud.go +++ b/internal/runtime/multimodal/voice/cloud.go @@ -15,17 +15,17 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// cloudMaxResponseBytes caps how much of a cloud voice response is read, so an -// oversized or malformed response cannot exhaust memory. The request timeout -// bounds a single call. +// cloudMaxResponseBytes 限制读取云端语音响应的体积,使过大 +// 或畸形的响应无法耗尽内存。请求超时 +// 约束单次调用。 const ( cloudMaxResponseBytes = 8 << 20 cloudHTTPTimeout = 180 * time.Second ) -// HTTPSTT transcribes audio by POSTing a JSON body to a cloud endpoint and -// reading {"text": "..."} back. It is the stubbed default STT implementation; -// the real DashScope/whisper-facing adapter is an adaptation point. +// HTTPSTT 通过向云端 endpoint POST 一个 JSON 请求体并 +// 读回 {"text": "..."} 来转写音频。它是桩式的默认 STT 实现; +// 真正面向 DashScope/whisper 的适配器是一个适配点。 type HTTPSTT struct { endpoint string apiKey string @@ -33,8 +33,8 @@ type HTTPSTT struct { log *slog.Logger } -// HTTPTTS synthesizes speech by POSTing a JSON body and reading the returned -// base64 audio. It is the stubbed default TTS implementation. +// HTTPTTS 通过 POST 一个 JSON 请求体并读取返回的 +// base64 音频来合成语音。它是桩式的默认 TTS 实现。 type HTTPTTS struct { endpoint string apiKey string @@ -42,7 +42,7 @@ type HTTPTTS struct { log *slog.Logger } -// HTTPOption customizes the client and logger of an HTTPSTT/HTTPTTS. +// HTTPOption 定制 HTTPSTT/HTTPTTS 的 client 与日志器。 type HTTPOption func(*httpConfig) type httpConfig struct { @@ -50,7 +50,7 @@ type httpConfig struct { log *slog.Logger } -// WithHTTPClient sets the *http.Client used for the call; nil keeps the default. +// WithHTTPClient 设置该调用所用的 *http.Client;nil 表示保持默认。 func WithHTTPClient(c *http.Client) HTTPOption { return func(o *httpConfig) { if c != nil { @@ -59,7 +59,7 @@ func WithHTTPClient(c *http.Client) HTTPOption { } } -// WithLogger sets the logger used on failures; nil keeps a discard logger. +// WithLogger 设置失败时使用的日志器;nil 表示保持丢弃日志器。 func WithLogger(l *slog.Logger) HTTPOption { return func(o *httpConfig) { if l != nil { @@ -68,7 +68,7 @@ func WithLogger(l *slog.Logger) HTTPOption { } } -// NewHTTPSTT builds an HTTPSTT for the given endpoint and API key. +// NewHTTPSTT 为给定 endpoint 与 API key 构造一个 HTTPSTT。 func NewHTTPSTT(endpoint, apiKey string, opts ...HTTPOption) *HTTPSTT { h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()} for _, o := range opts { @@ -77,7 +77,7 @@ func NewHTTPSTT(endpoint, apiKey string, opts ...HTTPOption) *HTTPSTT { return &HTTPSTT{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log} } -// NewHTTPTTS builds an HTTPTTS for the given endpoint and API key. +// NewHTTPTTS 为给定 endpoint 与 API key 构造一个 HTTPTTS。 func NewHTTPTTS(endpoint, apiKey string, opts ...HTTPOption) *HTTPTTS { h := &httpConfig{client: defaultHTTPClient(), log: discardLogger()} for _, o := range opts { @@ -86,19 +86,19 @@ func NewHTTPTTS(endpoint, apiKey string, opts ...HTTPOption) *HTTPTTS { return &HTTPTTS{endpoint: strings.TrimRight(endpoint, "/"), apiKey: apiKey, client: h.client, log: h.log} } -// sttRequest is the JSON body sent to the STT endpoint. +// sttRequest 是发往 STT endpoint 的 JSON 请求体。 type sttRequest struct { Audio string `json:"audio"` Format string `json:"format"` } -// sttResponse is the transcription returned by the STT endpoint. +// sttResponse 是 STT endpoint 返回的转写。 type sttResponse struct { Text string `json:"text"` } -// STT posts the audio and returns the recognized text. A blank endpoint returns -// ErrNotConfigured so the chain degrades. +// STT 发送音频并返回识别出的文字。endpoint 为空时返回 +// ErrNotConfigured,链随之降级。 func (s *HTTPSTT) STT(ctx context.Context, audio Audio) (string, error) { if s.endpoint == "" { return "", ErrNotConfigured @@ -122,19 +122,19 @@ func (s *HTTPSTT) STT(ctx context.Context, audio Audio) (string, error) { return decoded.Text, nil } -// ttsRequest is the JSON body sent to the TTS endpoint. +// ttsRequest 是发往 TTS endpoint 的 JSON 请求体。 type ttsRequest struct { Text string `json:"text"` } -// ttsResponse is the synthesized audio returned by the TTS endpoint. +// ttsResponse 是 TTS endpoint 返回的合成音频。 type ttsResponse struct { Audio string `json:"audio"` Format string `json:"format"` } -// TTS posts the text and returns the decoded audio. A blank endpoint returns -// ErrNotConfigured so the chain degrades. +// TTS 发送文本并返回解码后的音频。endpoint 为空时返回 +// ErrNotConfigured,链随之降级。 func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) { if s.endpoint == "" { return Audio{}, ErrNotConfigured @@ -169,7 +169,7 @@ func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) { return Audio{Format: format, Data: data}, nil } -// postJSON POSTs a JSON payload, wires auth, and checks the status code. +// postJSON POST 一个 JSON 载荷,接好鉴权并检查状态码。 func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payload []byte) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { @@ -190,41 +190,41 @@ func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payl return resp, nil } -// defaultHTTPClient builds a client with a request timeout. A fresh client is -// returned per call rather than a package global, which the constraints forbid. +// defaultHTTPClient 构造一个带请求超时的 client。这里每次调用 +// 返回新 client,而不是包级全局变量——后者为约束所禁止。 func defaultHTTPClient() *http.Client { return &http.Client{Transport: http.DefaultTransport, Timeout: cloudHTTPTimeout} } -// discardLogger returns a logger that drops all output. +// discardLogger 返回一个丢弃全部输出的日志器。 func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } -// newDashboardSTT returns the cloud STT adapter for a DashScope config. A call -// returns ErrNotConfigured when no API key is set so the chain degrades. +// newDashboardSTT 为 DashScope 配置返回云端 STT 适配器。未设置 +// API key 时调用会返回 ErrNotConfigured,链随之降级。 func newDashboardSTT(cfg config.VoiceConfig) STT { return NewDashscopeSTT(cfg) } -// newDashboardTTS returns the cloud TTS adapter for a DashScope config. +// newDashboardTTS 为 DashScope 配置返回云端 TTS 适配器。 func newDashboardTTS(cfg config.VoiceConfig) TTS { return NewDashscopeTTS(cfg) } -// newDashboardCloner returns the cloud voice-enrollment cloner for a DashScope -// config, used by the create_voice (声音复刻) endpoint. +// newDashboardCloner 为 DashScope 配置返回云端音色注册 cloner, +// 供 create_voice(声音复刻)endpoint 使用。 func newDashboardCloner(cfg config.VoiceConfig) VoiceCloner { return NewDashscopeCloner(cfg) } -// newEdgeTTS returns the edge-tts adapter point (not yet implemented). +// newEdgeTTS 返回 edge-tts 适配点(尚未实现)。 func newEdgeTTS() TTS { return notImplementedTTS{} } -// newEspeak returns the espeak adapter point (not yet implemented). +// newEspeak 返回 espeak 适配点(尚未实现)。 func newEspeak() TTS { return notImplementedTTS{} } -// notImplementedTTS is an adaptation point that always degrades. +// notImplementedTTS 是一个总是降级的适配点。 type notImplementedTTS struct{} func (notImplementedTTS) TTS(_ context.Context, _ string) (Audio, error) { diff --git a/internal/runtime/multimodal/voice/dashscope.go b/internal/runtime/multimodal/voice/dashscope.go index a0831a3..f744b45 100644 --- a/internal/runtime/multimodal/voice/dashscope.go +++ b/internal/runtime/multimodal/voice/dashscope.go @@ -14,37 +14,37 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// DashScope (Alibaba Bailian) STT/TTS adapters for the cloud rung of the voice -// chain. Requests follow the DashScope public API: +// 语音链云端一级的 DashScope(阿里百炼)STT/TTS 适配器。请求 +// 遵循 DashScope 公开 API: // // - STT: POST {base}/services/aigc/multimodal-generation/generation -// (qwen-audio-3.0-asr-flash, base64 input audio) +// (qwen-audio-3.0-asr-flash,base64 输入音频) // - TTS: POST {base}/services/audio/tts/SpeechSynthesizer -// (cosyvoice-v3-flash, synthesized by voice_id) +// (cosyvoice-v3-flash,按 voice_id 合成) // -// Both take a non-streaming response here: STT returns the transcript in the -// output envelope; TTS (no X-DashScope-SSE header) returns a 24h audio URL that -// we download so the caller always gets the actual bytes. Audio format ↔ mime -// is mapped in both directions (mimeOf / the data: URI in the STT request). +// 两者在这里都取非流式响应:STT 从 output envelope 中返回 +// 转写;TTS(不带 X-DashScope-SSE 头)返回一个 24h 音频 URL,我们 +// 把它下载下来,使调用方总能拿到真实字节。音频格式 ↔ mime +// 双向映射(mimeOf / STT 请求中的 data: URI)。 const ( - // defaultSTTSampleRate is the input audio sample rate the ASR expects. + // defaultSTTSampleRate 是 ASR 期望的输入音频采样率。 defaultSTTSampleRate = 16000 - // dashscopeSTTPath is the multimodal-generation service path (ASR). + // dashscopeSTTPath 是 multimodal-generation 服务路径(ASR)。 dashscopeSTTPath = "/services/aigc/multimodal-generation/generation" - // dashscopeTTSPath is the SpeechSynthesizer service path (TTS). + // dashscopeTTSPath 是 SpeechSynthesizer 服务路径(TTS)。 dashscopeTTSPath = "/services/audio/tts/SpeechSynthesizer" - // dashscopeClonePath is the voice-customization (声音复刻 create_voice) path. + // dashscopeClonePath 是音色定制(声音复刻 create_voice)路径。 dashscopeClonePath = "/services/audio/tts/customization" - // defaultDashscopeBaseURL is the Bailian API root when none is configured. + // defaultDashscopeBaseURL 是未配置时使用的百炼 API 根地址。 defaultDashscopeBaseURL = "https://dashscope.aliyuncs.com/api/v1" - // defaultClonePrefix is the voice-name prefix when none is supplied. + // defaultClonePrefix 是未提供时的音色名前缀。 defaultClonePrefix = "cyrene" - // voiceEnrollmentModel is the DashScope voice-enrollment model ("create_voice"). + // voiceEnrollmentModel 是 DashScope 音色注册模型("create_voice")。 voiceEnrollmentModel = "voice-enrollment" ) -// DashscopeSTT transcribes audio via DashScope multimodal-generation. +// DashscopeSTT 经 DashScope multimodal-generation 转写音频。 type DashscopeSTT struct { endpoint string apiKey string @@ -55,8 +55,8 @@ type DashscopeSTT struct { client *http.Client } -// NewDashscopeSTT builds the DashScope STT adapter from cfg. Missing model / -// language / base URL fall back to the DashScope defaults. +// NewDashscopeSTT 从 cfg 构造 DashScope STT 适配器。缺失的 model / +// language / base URL 回退到 DashScope 默认值。 func NewDashscopeSTT(cfg config.VoiceConfig) *DashscopeSTT { model := firstNonEmpty(cfg.STTModel, "qwen-audio-3.0-asr-flash") language := firstNonEmpty(cfg.STTLanguage, "zh") @@ -71,7 +71,7 @@ func NewDashscopeSTT(cfg config.VoiceConfig) *DashscopeSTT { } } -// STT posts the audio as a base64 data: URI and returns the recognized text. +// STT 把音频作为 base64 data: URI 发送并返回识别出的文字。 func (s *DashscopeSTT) STT(ctx context.Context, audio Audio) (string, error) { if s.apiKey == "" { return "", ErrNotConfigured @@ -119,7 +119,7 @@ func (s *DashscopeSTT) STT(ctx context.Context, audio Audio) (string, error) { return text, nil } -// DashscopeTTS synthesizes speech video via DashScope SpeechSynthesizer. +// DashscopeTTS 经 DashScope SpeechSynthesizer 合成语音。 type DashscopeTTS struct { endpoint string apiKey string @@ -131,8 +131,8 @@ type DashscopeTTS struct { client *http.Client } -// NewDashscopeTTS builds the DashScope TTS adapter from cfg. Missing model / -// voice / format / base URL fall back to the DashScope defaults. +// NewDashscopeTTS 从 cfg 构造 DashScope TTS 适配器。缺失的 model / +// voice / format / base URL 回退到 DashScope 默认值。 func NewDashscopeTTS(cfg config.VoiceConfig) *DashscopeTTS { model := firstNonEmpty(cfg.TTSModel, "cosyvoice-v3-flash") voiceID := firstNonEmpty(cfg.TTSVoiceID, "longxiaochun_v3") @@ -153,9 +153,9 @@ func NewDashscopeTTS(cfg config.VoiceConfig) *DashscopeTTS { } } -// TTS synthesizes audio for text. The non-streaming DashScope response carries a -// 24h audio URL (or a base64 blob); we resolve it to bytes and return them so the -// caller holds a complete audio file. +// TTS 为文字合成音频。非流式的 DashScope 响应携带一个 +// 24h 音频 URL(或一段 base64);我们把它解析成字节并返回, +// 使调用方拿到完整的音频文件。 func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) { if t.apiKey == "" { return Audio{}, ErrNotConfigured @@ -182,7 +182,7 @@ func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) { if err != nil { return Audio{}, fmt.Errorf("voice: %w: marshal: %v", ErrProviderFailed, err) } - // No X-DashScope-SSE header → non-streaming URL response. + // 不带 X-DashScope-SSE 头 → 返回非流式 URL 响应。 body, err := dashscopePost(ctx, t.client, t.apiKey, t.endpoint, payload, "") if err != nil { return Audio{}, err @@ -194,11 +194,11 @@ func (t *DashscopeTTS) TTS(ctx context.Context, text string) (Audio, error) { return Audio{Format: t.format, Data: data}, nil } -// EffectiveInstruction returns the instruction to send for this synthesis: a -// per-request override threaded through ctx wins, else the configured default. An -// empty result means the request carries no instruction. A system voice never gets -// a free-form instruction (see canInstruct), because cosyvoice v3 system voices -// require a fixed instruction format. +// EffectiveInstruction 返回本次合成要发送的 instruction:经 ctx 透传的 +// 逐请求覆盖优先,否则用配置的默认值。结果为空表示该请求 +// 不携带 instruction。系统音色永远不会收到自由形式的 instruction +// (见 canInstruct),因为 cosyvoice v3 系统音色 +// 要求固定的 instruction 格式。 func (t *DashscopeTTS) EffectiveInstruction(ctx context.Context) string { if !canInstruct(t.voiceID) { return "" @@ -209,16 +209,16 @@ func (t *DashscopeTTS) EffectiveInstruction(ctx context.Context) string { return t.instruction } -// canInstruct reports whether voiceID accepts a free-form instruction. cosyvoice v3 -// system voices (the long*_v3 family, e.g. longxiaochun_v3) require a fixed -// instruction format; a cloned/designed voice (声音复刻, whose id never ends in -// "_v3") accepts an arbitrary instruction. +// canInstruct 报告 voiceID 是否接受自由形式的 instruction。cosyvoice v3 +// 系统音色(long*_v3 系列,例如 longxiaochun_v3)要求固定的 +// instruction 格式;复刻/设计音色(声音复刻,其 id 永不以 +// "_v3" 结尾)接受任意 instruction。 func canInstruct(voiceID string) bool { return !strings.HasSuffix(voiceID, "_v3") } -// audioBytes resolves the synthesized audio from the DashScope response: a 24h -// URL is downloaded, or an inline base64 blob is decoded. +// audioBytes 从 DashScope 响应中解析合成音频:24h +// URL 会被下载,内联的 base64 块会被解码。 func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, error) { var r dashscopeTTSResponse if err := json.Unmarshal(body, &r); err != nil { @@ -241,8 +241,8 @@ func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, err return nil, fmt.Errorf("voice: %w: no audio in response", ErrProviderFailed) } -// download fetches an audio URL into memory, capping the size so an oversized -// media response cannot exhaust memory. +// download 把音频 URL 抓进内存,并限制体积,使过大的 +// 媒体响应无法耗尽内存。 func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -263,10 +263,10 @@ func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error) return data, nil } -// DashscopeCloner creates custom TTS voices via the DashScope voice-enrollment -// API (声音复刻 create_voice). The reference audio must be a public URL — the -// create_voice action only accepts input.url (no base64). The returned voice_id is -// bound to TTSModel, so target_model must match the model used at synthesis time. +// DashscopeCloner 经 DashScope 音色注册 API +// (声音复刻 create_voice)创建自定义 TTS 音色。参考音频必须是公开 +// URL —— create_voice 动作只接受 input.url(不支持 base64)。返回的 voice_id +// 绑定到 TTSModel,因此 target_model 必须与合成时使用的模型一致。 type DashscopeCloner struct { endpoint string apiKey string @@ -274,9 +274,9 @@ type DashscopeCloner struct { client *http.Client } -// NewDashscopeCloner builds the DashScope voice cloner from cfg. Missing model / -// base URL fall back to the DashScope defaults; the model tracks TTSModel so the -// cloned voice is usable by the TTS rung (target_model must match). +// NewDashscopeCloner 从 cfg 构造 DashScope 音色复刻器。缺失的 model / +// base URL 回退到 DashScope 默认值;model 跟随 TTSModel,使 +// 复刻出的音色可被 TTS 一级使用(target_model 必须一致)。 func NewDashscopeCloner(cfg config.VoiceConfig) *DashscopeCloner { return &DashscopeCloner{ endpoint: dashscopeEndpoint(cfg.DashscopeBaseURL) + dashscopeClonePath, @@ -286,9 +286,9 @@ func NewDashscopeCloner(cfg config.VoiceConfig) *DashscopeCloner { } } -// CloneVoice submits a create_voice request and returns the created voice_id. It -// is the adapter behind the REST /api/v1/audio/voice-clone endpoint. A missing API -// key returns ErrNotConfigured; an empty URL or an invalid prefix is rejected. +// CloneVoice 提交一次 create_voice 请求并返回创建出的 voice_id。它 +// 是 REST /api/v1/audio/voice-clone endpoint 背后的适配器。缺少 API +// key 时返回 ErrNotConfigured;空 URL 或非法 prefix 会被拒绝。 func (d *DashscopeCloner) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) { if d.apiKey == "" { return "", ErrNotConfigured @@ -333,9 +333,9 @@ func (d *DashscopeCloner) CloneVoice(ctx context.Context, referenceURL, prefix s return resp.Output.VoiceID, nil } -// normalizeClonePrefix trims and validates the voice-name prefix. An empty value -// falls back to defaultClonePrefix; the result must be ≤10 letter/digit chars -// (the voice-enrollment prefix constraint). +// normalizeClonePrefix 去空白并校验音色名前缀。空值 +// 回退到 defaultClonePrefix;结果必须是 ≤10 个字母/数字字符 +// (音色注册对 prefix 的约束)。 func normalizeClonePrefix(prefix string) (string, error) { prefix = strings.TrimSpace(prefix) if prefix == "" { @@ -352,8 +352,8 @@ func normalizeClonePrefix(prefix string) (string, error) { return prefix, nil } -// dashscopeEndpoint returns the DashScope API base with a trailing slash trimmed, -// defaulting to the public Bailian root when none is configured. +// dashscopeEndpoint 返回去掉尾部斜杠的 DashScope API 根地址, +// 未配置时默认为公开的百炼根地址。 func dashscopeEndpoint(baseURL string) string { base := strings.TrimRight(baseURL, "/") if base == "" { @@ -362,7 +362,7 @@ func dashscopeEndpoint(baseURL string) string { return base } -// firstNonEmpty returns a when non-empty, else b. +// firstNonEmpty 在 a 非空时返回 a,否则返回 b。 func firstNonEmpty(a, b string) string { if a != "" { return a @@ -370,9 +370,9 @@ func firstNonEmpty(a, b string) string { return b } -// dashscopePost POSTs a JSON payload with the DashScope bearer auth and an -// optional X-DashScope-SSE header, returning the decoded response body. A non-200 -// status is wrapped with the DashScope error body. +// dashscopePost POST 一个 JSON 载荷,带上 DashScope bearer 鉴权与可选的 +// X-DashScope-SSE 头,返回解码后的响应体。非 200 +// 状态会被包装上 DashScope 的错误体。 func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string, payload []byte, sse string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { @@ -400,8 +400,8 @@ func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string, return body, nil } -// extractSTTText pulls the transcript out of a DashScope ASR response. The ASR -// output shape varies by model, so several candidate paths are tried. +// extractSTTText 从 DashScope ASR 响应中取出转写。ASR +// 的输出形状随模型而异,因此按多个候选路径依次尝试。 func extractSTTText(body []byte) (string, error) { var r dashscopeSTTResponse if err := json.Unmarshal(body, &r); err != nil { @@ -426,7 +426,7 @@ func extractSTTText(body []byte) (string, error) { return r.Text, nil } -// mimeOf maps an audio format to its MIME type for the data: URI / Content-Type. +// mimeOf 把音频格式映射为其 MIME 类型,供 data: URI / Content-Type 使用。 func mimeOf(format string) string { switch strings.ToLower(format) { case "mp3", "mpeg": @@ -440,7 +440,7 @@ func mimeOf(format string) string { } } -// dashscopeSTTResponse is the DashScope ASR response envelope. +// dashscopeSTTResponse 是 DashScope ASR 响应 envelope。 type dashscopeSTTResponse struct { Output struct { Output struct { @@ -461,7 +461,7 @@ type dashscopeSTTResponse struct { Text string `json:"text"` } -// dashscopeTTSResponse is the DashScope TTS response envelope. +// dashscopeTTSResponse 是 DashScope TTS 响应 envelope。 type dashscopeTTSResponse struct { Output struct { Audio struct { @@ -471,66 +471,66 @@ type dashscopeTTSResponse struct { } `json:"output"` } -// dashscopeSTTRequest is the ASR request body. +// dashscopeSTTRequest 是 ASR 请求体。 type dashscopeSTTRequest struct { Model string `json:"model"` Input dashscopeSTTInput `json:"input"` Parameters dashscopeSTTParameters `json:"parameters"` } -// dashscopeSTTInput carries the multimodal messages list. +// dashscopeSTTInput 承载多模态消息列表。 type dashscopeSTTInput struct { Messages []dashscopeSTTMessage `json:"messages"` } -// dashscopeSTTMessage is one ASR user turn. +// dashscopeSTTMessage 是一个 ASR 用户回合。 type dashscopeSTTMessage struct { Role string `json:"role"` Content []dashscopeSTTContent `json:"content"` } -// dashscopeSTTContent is one audio content block. +// dashscopeSTTContent 是一个音频内容块。 type dashscopeSTTContent struct { Type string `json:"type"` InputAudio dashscopeInputAudio `json:"input_audio"` } -// dashscopeInputAudio carries the base64 data: URI audio. +// dashscopeInputAudio 承载 base64 data: URI 音频。 type dashscopeInputAudio struct { Data string `json:"data"` } -// dashscopeSTTParameters tunes the ASR request. +// dashscopeSTTParameters 调整 ASR 请求。 type dashscopeSTTParameters struct { Format string `json:"format"` SampleRate int `json:"sample_rate"` LanguageHints []string `json:"language_hints,omitempty"` } -// dashscopeTTSRequest is the TTS request body. +// dashscopeTTSRequest 是 TTS 请求体。 type dashscopeTTSRequest struct { Model string `json:"model"` Input dashscopeTTSInput `json:"input"` } -// dashscopeTTSInput carries text + voice + output format. +// dashscopeTTSInput 承载文字 + 音色 + 输出格式。 type dashscopeTTSInput struct { Text string `json:"text"` Voice string `json:"voice"` Format string `json:"format"` SampleRate int `json:"sample_rate"` - // Instruction is the CosyVoice emotion/tone instruction; omitempty keeps a - // system voice (which must use a fixed instruction) free of a free-form one. + // Instruction 是 CosyVoice 的情绪/语气 instruction;omitempty 使 + // 系统音色(必须使用固定 instruction)不携带自由形式的 instruction。 Instruction string `json:"instruction,omitempty"` } -// dashscopeCloneRequest is the voice-enrollment create_voice request body. +// dashscopeCloneRequest 是音色注册 create_voice 请求体。 type dashscopeCloneRequest struct { Model string `json:"model"` Input dashscopeCloneInput `json:"input"` } -// dashscopeCloneInput carries the create_voice action parameters. +// dashscopeCloneInput 承载 create_voice 动作参数。 type dashscopeCloneInput struct { Action string `json:"action"` TargetModel string `json:"target_model"` @@ -538,8 +538,8 @@ type dashscopeCloneInput struct { URL string `json:"url"` } -// dashscopeCloneResponse is the voice-enrollment response envelope. A top-level -// message marks a model-level error surfaced even on an HTTP 200. +// dashscopeCloneResponse 是音色注册响应 envelope。顶层 +// message 表示模型级错误——即使 HTTP 200 也会暴露。 type dashscopeCloneResponse struct { Output struct { VoiceID string `json:"voice_id"` diff --git a/internal/runtime/multimodal/voice/dashscope_live_test.go b/internal/runtime/multimodal/voice/dashscope_live_test.go index 457a5a6..0eddf7e 100644 --- a/internal/runtime/multimodal/voice/dashscope_live_test.go +++ b/internal/runtime/multimodal/voice/dashscope_live_test.go @@ -12,9 +12,9 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// TestDashscopeLive verifies the real Alibaba Bailian STT + TTS end to end. It is -// excluded from the default suite (build tag dashscope_live) so `go test ./...` -// never hits the paid API. Run it explicitly with the key sourced from .env: +// TestDashscopeLive 端到端验证真实的阿里百炼 STT + TTS。它 +// 被排除在默认测试集之外(构建标签 dashscope_live),使 `go test ./...` +// 绝不触碰付费 API。用从 .env 取到的 key 显式运行: // // set -a; source .env; set +a // go test -tags dashscope_live -run TestDashscopeLive -v ./internal/runtime/multimodal/voice/ @@ -25,7 +25,7 @@ func TestDashscopeLive(t *testing.T) { } cfg := config.VoiceConfig{DashscopeAPIKey: key} - // TTS: synthesize speech for a short line. + // TTS:为一句短文本合成语音。 tts := NewDashscopeTTS(cfg) audio, err := tts.TTS(context.Background(), "你好,我是昔涟") if err != nil { @@ -36,7 +36,7 @@ func TestDashscopeLive(t *testing.T) { t.Fatal("TTS returned empty audio") } - // STT closed loop: transcribe the synthesized speech back to text. + // STT 闭环:把合成的语音转写回文字。 stt := NewDashscopeSTT(cfg) text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: audio.Data}) if err != nil { @@ -44,7 +44,7 @@ func TestDashscopeLive(t *testing.T) { } t.Logf("STT (of TTS audio): text=%q", text) - // STT on a generated silence wav, to confirm the empty-audio path is accepted. + // 对生成的静音 wav 做 STT,确认空音频路径可被接受。 silence := silentWAVBytes(1, 16000) sttText, sttErr := stt.STT(context.Background(), Audio{Format: "wav", Data: silence}) if sttErr != nil { @@ -53,8 +53,8 @@ func TestDashscopeLive(t *testing.T) { t.Logf("STT (silence): text=%q", sttText) } - // Exercise the degrading chain (the same object the gateway wires), proving it - // returns real bytes/text rather than degrading to the silent terminal rung. + // 走一遍降级链(网关接线的就是同一个对象),证明它 + // 返回真实的字节/文字,而不是降级到静默末端一级。 ch := New(cfg) chTTS, chTTSErr := ch.TTS(context.Background(), "你好,我是昔涟") if chTTSErr != nil { @@ -74,21 +74,21 @@ func TestDashscopeLive(t *testing.T) { } } -// silentWAVBytes builds a mono 16-bit 16 kHz silence WAV of durS seconds. +// silentWAVBytes 构造 durS 秒的单声道 16 位 16 kHz 静音 WAV。 func silentWAVBytes(durS, sampleRate int) []byte { dataLen := durS * sampleRate * 2 buf := &bytes.Buffer{} - // RIFF header + fmt chunk + data chunk. + // RIFF 头 + fmt 块 + data 块。 buf.WriteString("RIFF") _ = binary.Write(buf, binary.LittleEndian, uint32(36+dataLen)) buf.WriteString("WAVEfmt ") _ = binary.Write(buf, binary.LittleEndian, uint32(16)) _ = binary.Write(buf, binary.LittleEndian, uint16(1)) // PCM - _ = binary.Write(buf, binary.LittleEndian, uint16(1)) // mono + _ = binary.Write(buf, binary.LittleEndian, uint16(1)) // 单声道 _ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate)) _ = binary.Write(buf, binary.LittleEndian, uint32(sampleRate*2)) - _ = binary.Write(buf, binary.LittleEndian, uint16(2)) // block align - _ = binary.Write(buf, binary.LittleEndian, uint16(16)) // bits per sample + _ = binary.Write(buf, binary.LittleEndian, uint16(2)) // 块对齐 + _ = binary.Write(buf, binary.LittleEndian, uint16(16)) // 每样本位数 buf.WriteString("data") _ = binary.Write(buf, binary.LittleEndian, uint32(dataLen)) buf.Write(make([]byte, dataLen)) diff --git a/internal/runtime/multimodal/voice/errors.go b/internal/runtime/multimodal/voice/errors.go index 69ebf7f..52790ae 100644 --- a/internal/runtime/multimodal/voice/errors.go +++ b/internal/runtime/multimodal/voice/errors.go @@ -2,17 +2,17 @@ package voice import "errors" -// Sentinel errors returned by the voice package. Each is wrapped with %w so -// callers can test for a category while keeping the cause. +// voice 包返回的哨兵错误。每个错误都以 %w 包装, +// 使调用方能按类别判断,同时保留原始原因。 var ( - // ErrProviderFailed is returned when every provider in a chain fails. + // ErrProviderFailed 在一条链上所有提供方都失败时返回。 ErrProviderFailed = errors.New("voice: all providers failed") - // ErrNotConfigured is returned when a provider has no endpoint/setting. + // ErrNotConfigured 在提供方缺少 endpoint/配置项时返回。 ErrNotConfigured = errors.New("voice: provider not configured") - // ErrNotImplemented is returned by an adaptation-point provider. + // ErrNotImplemented 由适配点提供方返回。 ErrNotImplemented = errors.New("voice: provider not implemented") - // ErrEmptyText is returned when TTS receives empty text. + // ErrEmptyText 在 TTS 收到空文本时返回。 ErrEmptyText = errors.New("voice: empty text") - // ErrClosed is returned when a service call arrives after Close. + // ErrClosed 在 Close 之后仍有服务调用到达时返回。 ErrClosed = errors.New("voice: service closed") ) diff --git a/internal/runtime/multimodal/voice/provider.go b/internal/runtime/multimodal/voice/provider.go index 38b8fb3..cbb3de9 100644 --- a/internal/runtime/multimodal/voice/provider.go +++ b/internal/runtime/multimodal/voice/provider.go @@ -2,76 +2,76 @@ package voice import "context" -// Provider is one rung in the voice degradation chain. It may implement zero, -// one, or both directions; a nil return means that direction is not available -// on this rung and the chain moves to the next. +// Provider 是语音降级链上的一级。它可实现零个、一个 +// 或两个方向;返回 nil 表示该方向在此级上不可用, +// 链会移向下一级。 type Provider interface { - // Name returns a stable provider identifier. + // Name 返回稳定的提供方标识。 Name() string - // STT returns the speech-to-text implementation, or nil when the provider - // cannot transcribe. + // STT 返回语音转文字实现,提供方无法转写时 + // 返回 nil。 STT() STT - // TTS returns the text-to-speech implementation, or nil when the provider - // cannot synthesize. + // TTS 返回文字转语音实现,提供方无法合成时 + // 返回 nil。 TTS() TTS } -// StaticProvider is a Provider with a fixed name whose STT/TTS are set at -// construction. It is used by the concrete providers below. +// StaticProvider 是名字固定、STT/TTS 在构造时设定的 +// Provider。下面各具体提供方都用它。 type StaticProvider struct { name string stt STT tts TTS } -// Name returns the provider identifier. +// Name 返回提供方标识。 func (p *StaticProvider) Name() string { return p.name } -// STT returns the configured STT, or nil. +// STT 返回配置的 STT,可能为 nil。 func (p *StaticProvider) STT() STT { return p.stt } -// TTS returns the configured TTS, or nil. +// TTS 返回配置的 TTS,可能为 nil。 func (p *StaticProvider) TTS() TTS { return p.tts } -// NewProvider builds a Provider with the given name and optional directions it -// supports. +// NewProvider 用给定名字及其支持的可选方向构造一个 +// Provider。 func NewProvider(name string, stt STT, tts TTS) *StaticProvider { return &StaticProvider{name: name, stt: stt, tts: tts} } -// CloudProvider is the cloud rung of the chain. It is an adaptation point: the -// real STT/TTS implementations are wired in via NewProvider with cloud adapters. +// CloudProvider 是链上的云端一级。它是一个适配点: +// 真实的 STT/TTS 实现经 NewProvider 与云端适配器接入。 func CloudProvider(stt STT, tts TTS) *StaticProvider { return NewProvider("cloud", stt, tts) } -// LocalProvider is the local-model rung of the chain. +// LocalProvider 是链上的本地模型一级。 func LocalProvider(stt STT, tts TTS) *StaticProvider { return NewProvider("local", stt, tts) } -// EdgeTTSProvider is the edge-tts rung (text-to-speech only). +// EdgeTTSProvider 是 edge-tts 一级(仅文字转语音)。 func EdgeTTSProvider(tts TTS) *StaticProvider { return NewProvider("edge-tts", nil, tts) } -// EspeakProvider is the espeak rung (text-to-speech only). +// EspeakProvider 是 espeak 一级(仅文字转语音)。 func EspeakProvider(tts TTS) *StaticProvider { return NewProvider("espeak", nil, tts) } -// SilentProvider is the terminal rung. It always succeeds, returning an empty -// transcription and empty audio, so a chain that includes it never errors. +// SilentProvider 是末端一级。它总是成功,返回空 +// 转写与空音频,因此包含它的链永不报错。 func SilentProvider() *StaticProvider { return NewProvider("silent", silenceSTT{}, silenceTTS{}) } -// silenceSTT is the silent STT rung; it returns an empty transcription. +// silenceSTT 是静默 STT 一级;它返回空转写。 type silenceSTT struct{} func (silenceSTT) STT(_ context.Context, _ Audio) (string, error) { return "", nil } -// silenceTTS is the silent TTS rung; it returns empty audio. +// silenceTTS 是静默 TTS 一级;它返回空音频。 type silenceTTS struct{} func (silenceTTS) TTS(_ context.Context, _ string) (Audio, error) { diff --git a/internal/runtime/multimodal/voice/service.go b/internal/runtime/multimodal/voice/service.go index 694d583..b3f368b 100644 --- a/internal/runtime/multimodal/voice/service.go +++ b/internal/runtime/multimodal/voice/service.go @@ -6,11 +6,11 @@ import ( "sync" ) -// Service runs STT/TTS requests on a background worker pool so caller -// goroutines (the agent loop) are never blocked by provider latency. Each -// request is enqueued on a jobs channel and answered on a per-job result -// channel; a canceled caller context or a closed service unblocks the caller -// immediately and no worker goroutine is leaked. It is safe for concurrent use. +// Service 在后台工作池上运行 STT/TTS 请求,使调用方 +// goroutine(智能体循环)绝不会被提供方延迟阻塞。每个 +// 请求入队到 jobs channel,并在每个 job 自己的结果 +// channel 上应答;调用方 context 被取消或服务已关闭时, +// 调用方立刻解除阻塞,且不泄漏任何 worker goroutine。它可安全并发使用。 type Service struct { chain *Chain jobs chan job @@ -22,11 +22,11 @@ type Service struct { closed bool } -// ServiceOption customizes a Service. +// ServiceOption 定制 Service。 type ServiceOption func(*Service) -// WithWorkers sets the worker-pool size. Values <= 0 are ignored and the -// default single worker is kept. +// WithWorkers 设置工作池大小。小于等于 0 的值被忽略, +// 保留默认的单 worker。 func WithWorkers(n int) ServiceOption { return func(s *Service) { if n > 0 { @@ -35,7 +35,7 @@ func WithWorkers(n int) ServiceOption { } } -// jobKind selects the operation a worker performs. +// jobKind 选择 worker 执行的操作。 type jobKind int const ( @@ -43,7 +43,7 @@ const ( jobTTS ) -// job is one request enqueued by the caller. +// job 是调用方入队的一个请求。 type job struct { kind jobKind audio Audio @@ -51,7 +51,7 @@ type job struct { res chan jobResult } -// jobResult carries the outcome written back to the caller. +// jobResult 承载写回调用方的结果。 type jobResult struct { text string audio Audio @@ -59,7 +59,7 @@ type jobResult struct { err error } -// NewService builds a Service backed by chain and starts the worker pool. +// NewService 构造一个由 chain 支撑的 Service,并启动工作池。 func NewService(chain *Chain, opts ...ServiceOption) *Service { bg, cancel := context.WithCancel(context.Background()) s := &Service{ @@ -79,7 +79,7 @@ func NewService(chain *Chain, opts ...ServiceOption) *Service { return s } -// worker consumes jobs until the service closes. +// worker 消费 job 直到服务关闭。 func (s *Service) worker() { defer s.wg.Done() for { @@ -92,10 +92,10 @@ func (s *Service) worker() { } } -// run executes a job and delivers the result. Providers run under the service's -// background context so Close cancels in-flight work and no worker goroutine is -// leaked; the result is written to a buffered channel so a caller that already -// returned never blocks the worker. +// run 执行一个 job 并投递结果。提供方在服务的 +// 后台 context 下运行,因此 Close 会取消在途工作,且不泄漏 +// worker goroutine;结果写入带缓冲的 channel,因此已 +// 返回的调用方绝不会阻塞该 worker。 func (s *Service) run(j job) { res := jobResult{} switch j.kind { @@ -107,15 +107,15 @@ func (s *Service) run(j job) { j.res <- res } -// STT enqueues a transcription request and waits for the result. ctx cancels -// the wait and the underlying provider call. +// STT 入队一个转写请求并等待结果。ctx 会取消 +// 等待以及底层提供方调用。 func (s *Service) STT(ctx context.Context, audio Audio) (string, error) { text, _, err := s.STTWithProvider(ctx, audio) return text, err } -// STTWithProvider is STT but also reports the provider rung that produced the -// transcription. +// STTWithProvider 即 STT,但额外报告产出该转写的 +// 提供方级别名。 func (s *Service) STTWithProvider(ctx context.Context, audio Audio) (string, string, error) { res, err := s.submit(ctx, job{kind: jobSTT, audio: audio, res: make(chan jobResult, 1)}) if err != nil { @@ -124,15 +124,15 @@ func (s *Service) STTWithProvider(ctx context.Context, audio Audio) (string, str return res.text, res.provider, res.err } -// TTS enqueues a synthesis request and waits for the result. ctx cancels the -// wait and the underlying provider call. +// TTS 入队一个合成请求并等待结果。ctx 会取消 +// 等待以及底层提供方调用。 func (s *Service) TTS(ctx context.Context, text string) (Audio, error) { audio, _, err := s.TTSWithProvider(ctx, text) return audio, err } -// TTSWithProvider is TTS but also reports the provider rung that produced the -// audio. +// TTSWithProvider 即 TTS,但额外报告产出该音频的 +// 提供方级别名。 func (s *Service) TTSWithProvider(ctx context.Context, text string) (Audio, string, error) { res, err := s.submit(ctx, job{kind: jobTTS, text: text, res: make(chan jobResult, 1)}) if err != nil { @@ -141,13 +141,13 @@ func (s *Service) TTSWithProvider(ctx context.Context, text string) (Audio, stri return res.audio, res.provider, res.err } -// CloneVoice creates a custom voice, delegating to the underlying chain. It is a -// one-shot admin operation (not a worker-pool job), so it runs directly. +// CloneVoice 创建自定义音色,交由底层链处理。它是一次性 +// 管理操作(不是工作池 job),因此直接运行。 func (s *Service) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) { return s.chain.CloneVoice(ctx, referenceURL, prefix) } -// submit enqueues j and blocks until the result or a cancellation. +// submit 把 j 入队并阻塞,直到拿到结果或发生取消。 func (s *Service) submit(ctx context.Context, j job) (jobResult, error) { select { case s.jobs <- j: @@ -166,9 +166,9 @@ func (s *Service) submit(ctx context.Context, j job) (jobResult, error) { } } -// Close cancels the worker pool and waits for in-flight jobs to finish. It is -// safe to call once; later calls are no-ops. In-flight provider calls are -// bounded by each provider's own context timeout, so Close returns promptly. +// Close 取消工作池并等待在途 job 结束。调用一次是 +// 安全的;后续调用为空操作。在途提供方调用由各提供方 +// 自己的 context 超时兜底,因此 Close 会迅速返回。 func (s *Service) Close() { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/runtime/multimodal/voice/service_test.go b/internal/runtime/multimodal/voice/service_test.go index 417dfa9..640877e 100644 --- a/internal/runtime/multimodal/voice/service_test.go +++ b/internal/runtime/multimodal/voice/service_test.go @@ -43,7 +43,7 @@ func TestService_CloseStopsNewCalls(t *testing.T) { } } -// blockingSTT blocks until its context is canceled, marking that it started. +// blockingSTT 会阻塞到它的 context 被取消,同时标记它已启动。 type blockingSTT struct { started chan struct{} once sync.Once diff --git a/internal/runtime/multimodal/voice/voice.go b/internal/runtime/multimodal/voice/voice.go index b6229a3..4a2296e 100644 --- a/internal/runtime/multimodal/voice/voice.go +++ b/internal/runtime/multimodal/voice/voice.go @@ -1,60 +1,60 @@ -// Package voice implements asynchronous speech-to-text and text-to-speech for -// the Cyrene runtime, following DevDocs/design/06-多模态.md §1. +// Package voice 为 Cyrene 运行时实现异步语音转文字与文字转语音, +// 遵循 DevDocs/design/06-多模态.md §1。 // -// Voice input (STT) is transcribed to text and fed into the same agent pipeline -// as a chat message; the reply is synthesized (TTS) back to audio. Both -// directions degrade through a provider chain — cloud → local → edge-tts/espeak -// → silent — so an unavailable provider falls through to the next rung instead -// of failing the whole turn. The stub default implementations are HTTP-based and -// testable with a stdlib httptest server; real cloud/local providers remain -// adaptation points for later milestones. +// 语音输入(STT)被转写为文字,并作为一条 chat 消息进入同一条智能体 +// 流水线;回复则被合成(TTS)回音频。两个 +// 方向都经一条提供方链降级 —— cloud → local → edge-tts/espeak +// → silent —— 因此某个提供方不可用时会落到下一级,而不是 +// 让整个 turn 失败。默认实现是 HTTP 桩实现,可用 +// 标准库 httptest 服务器测试;真实的云端/本地提供方仍作为 +// 后续里程碑的适配点。 package voice import "context" -// Audio is a piece of audio data plus its encoding. +// Audio 是一段音频数据及其编码。 type Audio struct { - // Format is the encoding, e.g. "wav", "mp3", "ogg". + // Format 是编码格式,例如 "wav"、"mp3"、"ogg"。 Format string - // Data is the raw audio bytes. + // Data 是原始音频字节。 Data []byte } -// STT converts audio to text. +// STT 把音频转换成文字。 type STT interface { - // STT transcribes audio and returns the spoken text. ctx controls - // cancellation and must be honored. + // STT 转写音频并返回说出的文字。ctx 控制 + // 取消,必须被遵守。 STT(ctx context.Context, audio Audio) (string, error) } -// TTS synthesizes speech audio from text. +// TTS 从文字合成语音音频。 type TTS interface { - // TTS synthesizes audio from text. ctx controls cancellation and must be - // honored. + // TTS 从文字合成音频。ctx 控制取消,必须 + // 被遵守。 TTS(ctx context.Context, text string) (Audio, error) } -// VoiceCloner creates a custom voice (声音复刻) from a public reference audio URL. -// A nil-cloner provider (no cloud rung configured) returns ErrNotConfigured. +// VoiceCloner 从公开的参考音频 URL 创建自定义音色(声音复刻)。 +// 未配置 cloner 的提供方(云端一级未配置)返回 ErrNotConfigured。 type VoiceCloner interface { - // CloneVoice creates a custom voice and returns its voice_id. referenceURL is - // a public audio URL; prefix is the voice-name prefix (≤10 alphanumeric chars). + // CloneVoice 创建自定义音色并返回其 voice_id。referenceURL 是 + // 公开音频 URL;prefix 是音色名前缀(≤10 个字母数字字符)。 CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) } -// ctxKeyTTSInstruction is the context key for a per-request TTS instruction. -// The TTS interface only carries (ctx, text), so an optional instruction is -// threaded through ctx to reach the adapter without widening the interface. +// ctxKeyTTSInstruction 是逐请求 TTS 指令的 context 键。 +// TTS 接口只携带 (ctx, text),因此可选指令经 +// ctx 透传以抵达适配器,而无需拓宽该接口。 type ctxKeyTTSInstruction struct{} -// WithTTSInstruction returns ctx carrying an optional per-request TTS instruction -// (emotion/tone) that overrides the configured VOICE_TTS_INSTRUCTION for one call. +// WithTTSInstruction 返回携带一条逐请求 TTS 指令 +// (情绪/语气)的 ctx,该指令对这一次调用覆盖配置里的 VOICE_TTS_INSTRUCTION。 func WithTTSInstruction(ctx context.Context, instruction string) context.Context { return context.WithValue(ctx, ctxKeyTTSInstruction{}, instruction) } -// TTSInstructionFromContext returns the per-request TTS instruction set by -// WithTTSInstruction, or "" when none is carried. +// TTSInstructionFromContext 返回由 WithTTSInstruction 设置的逐请求 +// TTS 指令,未携带时返回 ""。 func TTSInstructionFromContext(ctx context.Context) string { if v, ok := ctx.Value(ctxKeyTTSInstruction{}).(string); ok { return v diff --git a/internal/runtime/multimodal/voice/voice_clone_test.go b/internal/runtime/multimodal/voice/voice_clone_test.go index b576617..8a60285 100644 --- a/internal/runtime/multimodal/voice/voice_clone_test.go +++ b/internal/runtime/multimodal/voice/voice_clone_test.go @@ -12,8 +12,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) -// TestDashscopeCloner_Ok asserts the create_voice request body shape -// (model/action/target_model/prefix/url) and the voice_id parsing. +// TestDashscopeCloner_Ok 断言 create_voice 请求体的形状 +// (model/action/target_model/prefix/url)以及 voice_id 的解析。 func TestDashscopeCloner_Ok(t *testing.T) { t.Parallel() var gotBody map[string]any @@ -61,8 +61,8 @@ func TestDashscopeCloner_Ok(t *testing.T) { } } -// TestDashscopeCloner_DefaultPrefix asserts the empty prefix falls back to the -// configured VOICE_CLONE_PREFIX default ("cyrene"). +// TestDashscopeCloner_DefaultPrefix 断言空前缀回退到 +// 配置的 VOICE_CLONE_PREFIX 默认值("cyrene")。 func TestDashscopeCloner_DefaultPrefix(t *testing.T) { t.Parallel() var gotBody map[string]any @@ -83,8 +83,8 @@ func TestDashscopeCloner_DefaultPrefix(t *testing.T) { } } -// TestDashscopeCloner_TargetModelOverride asserts target_model tracks the config -// TTSModel (must match the synthesis model). +// TestDashscopeCloner_TargetModelOverride 断言 target_model 跟随配置的 +// TTSModel(必须与合成模型一致)。 func TestDashscopeCloner_TargetModelOverride(t *testing.T) { t.Parallel() var gotBody map[string]any @@ -105,7 +105,7 @@ func TestDashscopeCloner_TargetModelOverride(t *testing.T) { } } -// TestDashscopeCloner_NotConfigured asserts a missing API key degrades. +// TestDashscopeCloner_NotConfigured 断言缺少 API key 时降级。 func TestDashscopeCloner_NotConfigured(t *testing.T) { t.Parallel() cloner := NewDashscopeCloner(config.VoiceConfig{}) @@ -114,7 +114,7 @@ func TestDashscopeCloner_NotConfigured(t *testing.T) { } } -// TestDashscopeCloner_EmptyURL asserts an empty reference URL is rejected. +// TestDashscopeCloner_EmptyURL 断言空的参考 URL 被拒绝。 func TestDashscopeCloner_EmptyURL(t *testing.T) { t.Parallel() cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"}) @@ -123,8 +123,8 @@ func TestDashscopeCloner_EmptyURL(t *testing.T) { } } -// TestDashscopeCloner_BadPrefix asserts a non-alphanumeric or >10-char prefix is -// rejected before any request is sent. +// TestDashscopeCloner_BadPrefix 断言非字母数字或超过 10 字符的前缀 +// 在发出任何请求之前就被拒绝。 func TestDashscopeCloner_BadPrefix(t *testing.T) { t.Parallel() cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"}) @@ -135,8 +135,8 @@ func TestDashscopeCloner_BadPrefix(t *testing.T) { } } -// TestDashscopeCloner_ModelError asserts a top-level message in an HTTP 200 -// response is surfaced as an error (no voice_id). +// TestDashscopeCloner_ModelError 断言 HTTP 200 响应中的顶层 message +// 会被作为错误暴露出来(此时没有 voice_id)。 func TestDashscopeCloner_ModelError(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -151,8 +151,8 @@ func TestDashscopeCloner_ModelError(t *testing.T) { } } -// TestDashscopeTTS_Instruction_ClonedVoice asserts a cloned voice carries the -// configured instruction in the request input. +// TestDashscopeTTS_Instruction_ClonedVoice 断言复刻音色会在请求 input 中 +// 携带配置的 instruction。 func TestDashscopeTTS_Instruction_ClonedVoice(t *testing.T) { t.Parallel() var gotBody map[string]any @@ -182,8 +182,8 @@ func TestDashscopeTTS_Instruction_ClonedVoice(t *testing.T) { } } -// TestDashscopeTTS_NoInstruction_SystemVoice asserts a system voice (longxiaochun_v3) -// never receives a free-form instruction even when one is configured. +// TestDashscopeTTS_NoInstruction_SystemVoice 断言系统音色(longxiaochun_v3) +// 即使配置了 instruction 也绝不会收到自由形式的 instruction。 func TestDashscopeTTS_NoInstruction_SystemVoice(t *testing.T) { t.Parallel() var gotBody map[string]any @@ -210,8 +210,8 @@ func TestDashscopeTTS_NoInstruction_SystemVoice(t *testing.T) { } } -// TestDashscopeTTS_Instruction_ContextOverride asserts a per-request ctx -// instruction wins over the configured one for a cloned voice. +// TestDashscopeTTS_Instruction_ContextOverride 断言对复刻音色,逐请求的 ctx +// instruction 优先于配置的 instruction。 func TestDashscopeTTS_Instruction_ContextOverride(t *testing.T) { t.Parallel() var gotBody map[string]any