Files
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

236 lines
9.0 KiB
Go

package runtime
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"testing"
ai "code.tczkiot.com/wlw/ai-agent/internal/ai"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"github.com/cloudwego/eino/schema"
)
func TestPlatformEinoChatModelUsesStableRequestIDsAcrossRunRecovery(t *testing.T) {
var mu sync.Mutex
requestIDs := make([]string, 0, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
mu.Lock()
requestIDs = append(requestIDs, request.Header.Get("X-AI-Request-ID"))
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"platform-default","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
}))
t.Cleanup(server.Close)
requestContext := withPlatformRequestIDBase(context.Background(), "conversation:10:message:20:revision:30")
model, err := newEinoChatModel(requestContext, models.AIConfig{
APIKey: "platform-managed",
BaseURL: server.URL + "/v1",
ModelName: "platform-default",
Platform: true,
HTTPClient: server.Client(),
})
if err != nil {
t.Fatalf("newEinoChatModel() error = %v", err)
}
for range 2 {
if _, err = model.Generate(requestContext, []*schema.Message{schema.UserMessage("hello")}); err != nil {
t.Fatalf("Generate() error = %v", err)
}
}
recoveredModel, err := newEinoChatModel(requestContext, models.AIConfig{
APIKey: "platform-managed", BaseURL: server.URL + "/v1", ModelName: "platform-default",
Platform: true, HTTPClient: server.Client(),
})
if err != nil {
t.Fatalf("newEinoChatModel(recovered) error = %v", err)
}
for range 2 {
if _, err = recoveredModel.Generate(requestContext, []*schema.Message{schema.UserMessage("hello")}); err != nil {
t.Fatalf("recovered Generate() error = %v", err)
}
}
mu.Lock()
defer mu.Unlock()
if len(requestIDs) != 4 || requestIDs[0] == "" || requestIDs[1] == "" || requestIDs[0] == requestIDs[1] {
t.Fatalf("request IDs = %q, want distinct non-empty per-step values", requestIDs)
}
if requestIDs[0] != requestIDs[2] || requestIDs[1] != requestIDs[3] {
t.Fatalf("request IDs = %q, want recovered run to reuse stable per-step IDs", requestIDs)
}
}
func TestIsDeepSeekV4Model(t *testing.T) {
tests := []struct {
name string
config models.AIConfig
want bool
}{
{
name: "flash",
config: models.AIConfig{
BaseURL: "https://api.deepseek.com",
ModelName: "deepseek-v4-flash",
},
want: true,
},
{
name: "pro with whitespace",
config: models.AIConfig{
BaseURL: " https://api.deepseek.com/v1 ",
ModelName: " DeepSeek-V4-Pro ",
},
want: true,
},
{
name: "other openai compatible provider",
config: models.AIConfig{
BaseURL: "https://example.com/v1",
ModelName: "deepseek-v4-flash",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isDeepSeekV4Model(tt.config); got != tt.want {
t.Fatalf("isDeepSeekV4Model() = %v, want %v", got, tt.want)
}
})
}
}
func TestEinoFunctionToolNormalizesModelNameAndExecutesOriginalBusinessCode(t *testing.T) {
var executed ai.ToolCall
tool, err := newEinoFunctionTool(ai.ToolDefinition{
Name: "business/card_diagnosis",
Description: "Diagnose the current card.",
Parameters: map[string]any{"type": "object"},
}, func(_ context.Context, call ai.ToolCall) (string, error) {
executed = call
return "ok", nil
})
if err != nil {
t.Fatalf("newEinoFunctionTool() error = %v", err)
}
info, err := tool.Info(context.Background())
if err != nil {
t.Fatalf("Info() error = %v", err)
}
if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(info.Name) {
t.Fatalf("normalized tool name %q is not OpenAI compatible", info.Name)
}
if info.Name == "business/card_diagnosis" || len(info.Name) > 64 {
t.Fatalf("unexpected normalized tool name %q", info.Name)
}
result, err := tool.InvokableRun(context.Background(), `{"card":"current"}`)
if err != nil {
t.Fatalf("InvokableRun() error = %v", err)
}
if result != "ok" {
t.Fatalf("InvokableRun() = %q, want ok", result)
}
if executed.Name != "business/card_diagnosis" || executed.Arguments != `{"card":"current"}` {
t.Fatalf("executed call = %#v", executed)
}
}
func TestNormalizeEinoToolNameKeepsCompatibleName(t *testing.T) {
if got := normalizeEinoToolName("conversation_decision"); got != "conversation_decision" {
t.Fatalf("normalizeEinoToolName() = %q", got)
}
}
func TestBuildEinoUserMessageUsesTrustedInlineImages(t *testing.T) {
message := buildEinoUserMessage("请看设备指示灯", []ai.ImageInput{{
AssetID: "asset-1", MIMEType: "image/png", Base64Data: "aGVsbG8=",
}})
if message.Role != schema.User || message.Content != "" || len(message.UserInputMultiContent) != 4 {
t.Fatalf("unexpected multimodal message: %#v", message)
}
if message.UserInputMultiContent[2].Type != schema.ChatMessagePartTypeText || !strings.Contains(message.UserInputMultiContent[2].Text, "图1") {
t.Fatalf("image ordinal label missing: %#v", message.UserInputMultiContent[2])
}
imagePart := message.UserInputMultiContent[3]
if imagePart.Type != schema.ChatMessagePartTypeImageURL || imagePart.Image == nil || imagePart.Image.URL != nil || imagePart.Image.Base64Data == nil || *imagePart.Image.Base64Data != "aGVsbG8=" || imagePart.Image.MIMEType != "image/png" {
t.Fatalf("unexpected trusted image part: %#v", imagePart)
}
if imagePart.Image.Detail != schema.ImageURLDetailHigh {
t.Fatalf("device image must use high detail, got %q", imagePart.Image.Detail)
}
}
func TestSupportsVisionInputIsConservativeAndFallbackErrorsAreScoped(t *testing.T) {
for _, modelName := range []string{"qwen2.5-vl-max", "gpt-4o-mini", "gemini-2.5-flash"} {
if !supportsVisionInput(models.AIConfig{ModelName: modelName}) {
t.Fatalf("expected %q to support vision", modelName)
}
}
for _, modelName := range []string{"deepseek-v4-flash", "qwen-plus", "platform-default"} {
if supportsVisionInput(models.AIConfig{ModelName: modelName}) {
t.Fatalf("text-only/unknown model %q must degrade without image parts", modelName)
}
}
if supportsVisionInput(models.AIConfig{Platform: true, ModelName: "deepseek-v4-flash"}) {
t.Fatal("managed platform without an enabled vision route must reject image parts")
}
if !supportsVisionInput(models.AIConfig{Platform: true, VisionEnabled: true, VisionModel: "qwen3-vl-plus", ModelName: "deepseek-v4-flash"}) {
t.Fatal("managed platform with a configured vision route must preserve image parts")
}
if !isVisionUnsupportedError(errors.New("model does not support image content")) {
t.Fatal("expected image capability error to trigger text-only retry")
}
if isVisionUnsupportedError(errors.New("upstream timeout")) {
t.Fatal("unrelated upstream failures must not trigger a duplicate model call")
}
}
func TestVisionFallbackExplicitlyForbidsPretendingToSeeImage(t *testing.T) {
messages := []*schema.Message{schema.SystemMessage("base"), buildEinoUserMessage("看图", []ai.ImageInput{{MIMEType: "image/png", Base64Data: "aGVsbG8="}})}
fallback := buildVisionFallbackMessages(messages, "看图")
if len(fallback) != 3 || fallback[1].Role != schema.System || !strings.Contains(fallback[1].Content, "Never claim that you saw") || len(fallback[2].UserInputMultiContent) != 0 || fallback[2].Content != "看图" {
t.Fatalf("unsafe text-only vision fallback: %#v", fallback)
}
}
func TestEinoOpenAIAdapterSerializesInlineImageURLWithoutExternalURL(t *testing.T) {
var requestBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
data, err := io.ReadAll(request.Body)
if err != nil {
t.Errorf("read request body: %v", err)
}
requestBody = string(data)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"id":"chatcmpl-vision","object":"chat.completion","created":1,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"看到了"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)
}))
t.Cleanup(server.Close)
model, err := newEinoChatModel(context.Background(), models.AIConfig{
APIKey: "test", BaseURL: server.URL + "/v1", ModelName: "gpt-4o-mini", HTTPClient: server.Client(),
})
if err != nil {
t.Fatalf("newEinoChatModel() error = %v", err)
}
message := buildEinoUserMessage("分析照片", []ai.ImageInput{{MIMEType: "image/png", Base64Data: "aGVsbG8="}})
if _, err := model.Generate(context.Background(), []*schema.Message{message}); err != nil {
t.Fatalf("Generate() error = %v", err)
}
if !strings.Contains(requestBody, "data:image/png;base64,aGVsbG8=") {
t.Fatalf("request does not contain the expected inline image URL: %s", requestBody)
}
if strings.Contains(requestBody, "http://attacker") || strings.Contains(requestBody, "https://attacker") {
t.Fatalf("external URL leaked into vision request: %s", requestBody)
}
}