后端接入 AI 模型并支持测试账号托管回复
按协议而不是按厂商做适配,与现有短信、对象存储的做法一致:openai 兼容格式 一个适配器即可覆盖 DeepSeek、通义、智谱、火山方舟、Ollama 等,新增厂商通常 只需在后台加一条模型记录;anthropic、gemini、webhook、debug 各一个适配器。 密钥沿用 integration.go 的 AES-GCM 加密存储。 测试账号托管的回复走 persistMessage 同一条落库和推送路径,因此未读数、 WebSocket 推送和会话排序全部复用现有逻辑,客户端无需改动。为此把 persistMessage 抽出 persistMessageContext,因为 worker 没有 *http.Request。 任务队列用数据库表而非内存:重启不丢回复,多实例不重复消费。领取用 UPDATE 打 claim_token 再回读,没有用 SELECT ... FOR UPDATE SKIP LOCKED——生产存在 MySQL 5.7 环境,那里该语法无法解析。同一会话同时只允许一个待处理任务 (pending_key 生成列 + 唯一键),所以用户连发多条消息只会得到一条回复。 闸门:仅 is_test=1 且在允许批次内的账号生效,托管账号之间不互相触发,三层 配额,调用失败默认静默。会话内是否标注 AI 身份由 ai.disclose_in_chat 控制 并默认开启,关闭前需确认所在地区的监管要求。 app.go、integration.go、im.go 三个文件同时包含本次改动之前工作区里就已存在 的未提交修改,一并带入。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
334890171e
commit
a024d59827
@@ -0,0 +1,488 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
aiResponseLimit = 512 << 10
|
||||
aiProbePrompt = "用一句不超过 20 个字的中文打个招呼。"
|
||||
)
|
||||
|
||||
// Vendors are many but wire protocols are few, so adapters are written per
|
||||
// protocol. Anything speaking the OpenAI chat format needs no new code, only a
|
||||
// new row in ai_models.
|
||||
var aiProtocols = []string{"openai", "anthropic", "gemini", "webhook", "debug"}
|
||||
|
||||
type aiChatMessage struct {
|
||||
Role string // user 或 assistant
|
||||
Text string
|
||||
}
|
||||
|
||||
type aiChatRequest struct {
|
||||
System string
|
||||
Messages []aiChatMessage
|
||||
MaxTokens int
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
type aiChatResult struct {
|
||||
Text string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
FinishReason string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type aiProvider interface {
|
||||
Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error)
|
||||
}
|
||||
|
||||
type aiModel struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
ModelName string `json:"modelName"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"maxTokens"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
Status int `json:"status"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
APIKey string `json:"-"`
|
||||
HasAPIKey bool `json:"hasApiKey"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func aiProtocolName(protocol string) string {
|
||||
switch protocol {
|
||||
case "openai":
|
||||
return "OpenAI 兼容"
|
||||
case "anthropic":
|
||||
return "Anthropic Messages"
|
||||
case "gemini":
|
||||
return "Google Gemini"
|
||||
case "webhook":
|
||||
return "自定义 Webhook"
|
||||
case "debug":
|
||||
return "本地调试"
|
||||
}
|
||||
return protocol
|
||||
}
|
||||
|
||||
// The endpoint is operator-supplied, so it is validated the same way the SMS
|
||||
// endpoints are: no credentials, no query string, no fragment.
|
||||
func parseAIBaseURL(protocol, raw string, production bool) (*url.URL, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if protocol == "debug" {
|
||||
return nil, nil
|
||||
}
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("%s 需要填写接口地址", aiProtocolName(protocol))
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("接口地址必须是无账号、查询参数和片段的有效地址")
|
||||
}
|
||||
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && !production) {
|
||||
return nil, fmt.Errorf("接口地址必须使用 HTTPS")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func aiEndpoint(base *url.URL, suffix string) string {
|
||||
trimmed := strings.TrimRight(base.String(), "/")
|
||||
if suffix == "" || strings.HasSuffix(trimmed, suffix) {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed + suffix
|
||||
}
|
||||
|
||||
func newAIProvider(model aiModel, production bool) (aiProvider, error) {
|
||||
if !containsString(aiProtocols, model.Protocol) {
|
||||
return nil, fmt.Errorf("不支持的模型协议 %q", model.Protocol)
|
||||
}
|
||||
if model.Protocol == "debug" {
|
||||
if production {
|
||||
return nil, fmt.Errorf("生产环境禁止使用本地调试模型")
|
||||
}
|
||||
return debugAIProvider{}, nil
|
||||
}
|
||||
base, err := parseAIBaseURL(model.Protocol, model.BaseURL, production)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(model.APIKey) == "" && model.Protocol != "webhook" {
|
||||
return nil, fmt.Errorf("%s 需要填写密钥", aiProtocolName(model.Protocol))
|
||||
}
|
||||
if strings.TrimSpace(model.ModelName) == "" && model.Protocol != "webhook" {
|
||||
return nil, fmt.Errorf("请填写模型名称")
|
||||
}
|
||||
timeout := time.Duration(model.TimeoutMS) * time.Millisecond
|
||||
if timeout < time.Second || timeout > time.Minute {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
switch model.Protocol {
|
||||
case "openai":
|
||||
return openAIProvider{model: model, base: base, client: client}, nil
|
||||
case "anthropic":
|
||||
return anthropicProvider{model: model, base: base, client: client}, nil
|
||||
case "gemini":
|
||||
return geminiProvider{model: model, base: base, client: client}, nil
|
||||
}
|
||||
return webhookAIProvider{model: model, base: base, client: client}, nil
|
||||
}
|
||||
|
||||
func aiRequestTemperature(req aiChatRequest, model aiModel) float64 {
|
||||
if req.Temperature > 0 {
|
||||
return req.Temperature
|
||||
}
|
||||
return model.Temperature
|
||||
}
|
||||
|
||||
func aiRequestMaxTokens(req aiChatRequest, model aiModel) int {
|
||||
if req.MaxTokens > 0 {
|
||||
return req.MaxTokens
|
||||
}
|
||||
if model.MaxTokens > 0 {
|
||||
return model.MaxTokens
|
||||
}
|
||||
return 256
|
||||
}
|
||||
|
||||
func aiPostJSON(ctx context.Context, client *http.Client, endpoint string, headers map[string]string, payload any) ([]byte, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
for key, value := range headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("模型服务请求失败:%w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, aiResponseLimit))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取模型响应失败:%w", err)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("模型服务返回 HTTP %d:%s", response.StatusCode, aiErrorSnippet(raw))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// Provider error bodies are quoted back to the operator, so they are trimmed to
|
||||
// something a config page can display.
|
||||
func aiErrorSnippet(raw []byte) string {
|
||||
text := strings.TrimSpace(string(raw))
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
if len([]rune(text)) > 200 {
|
||||
return string([]rune(text)[:200]) + "…"
|
||||
}
|
||||
if text == "" {
|
||||
return "无响应内容"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
type openAIProvider struct {
|
||||
model aiModel
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p openAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
|
||||
messages := make([]map[string]string, 0, len(req.Messages)+1)
|
||||
if strings.TrimSpace(req.System) != "" {
|
||||
messages = append(messages, map[string]string{"role": "system", "content": req.System})
|
||||
}
|
||||
for _, message := range req.Messages {
|
||||
messages = append(messages, map[string]string{"role": message.Role, "content": message.Text})
|
||||
}
|
||||
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/chat/completions"),
|
||||
map[string]string{"Authorization": "Bearer " + p.model.APIKey},
|
||||
map[string]any{
|
||||
"model": p.model.ModelName,
|
||||
"messages": messages,
|
||||
"max_tokens": aiRequestMaxTokens(req, p.model),
|
||||
"temperature": aiRequestTemperature(req, p.model),
|
||||
"stream": false,
|
||||
})
|
||||
if err != nil {
|
||||
return aiChatResult{}, err
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
} `json:"usage"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &payload); err != nil {
|
||||
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON:%s", aiErrorSnippet(raw))
|
||||
}
|
||||
if payload.Error.Message != "" {
|
||||
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
|
||||
}
|
||||
if len(payload.Choices) == 0 {
|
||||
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
|
||||
}
|
||||
return aiChatResult{
|
||||
Text: strings.TrimSpace(payload.Choices[0].Message.Content),
|
||||
PromptTokens: payload.Usage.PromptTokens,
|
||||
CompletionTokens: payload.Usage.CompletionTokens,
|
||||
FinishReason: payload.Choices[0].FinishReason,
|
||||
RequestID: payload.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type anthropicProvider struct {
|
||||
model aiModel
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p anthropicProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
|
||||
messages := make([]map[string]any, 0, len(req.Messages))
|
||||
for _, message := range req.Messages {
|
||||
messages = append(messages, map[string]any{
|
||||
"role": message.Role,
|
||||
"content": []map[string]string{{"type": "text", "text": message.Text}},
|
||||
})
|
||||
}
|
||||
payloadBody := map[string]any{
|
||||
"model": p.model.ModelName,
|
||||
"messages": messages,
|
||||
"max_tokens": aiRequestMaxTokens(req, p.model),
|
||||
"temperature": aiRequestTemperature(req, p.model),
|
||||
}
|
||||
// Anthropic carries the system prompt outside the message list.
|
||||
if strings.TrimSpace(req.System) != "" {
|
||||
payloadBody["system"] = req.System
|
||||
}
|
||||
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/v1/messages"),
|
||||
map[string]string{"x-api-key": p.model.APIKey, "anthropic-version": "2023-06-01"}, payloadBody)
|
||||
if err != nil {
|
||||
return aiChatResult{}, err
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &payload); err != nil {
|
||||
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON:%s", aiErrorSnippet(raw))
|
||||
}
|
||||
if payload.Error.Message != "" {
|
||||
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
|
||||
}
|
||||
text := ""
|
||||
for _, block := range payload.Content {
|
||||
if block.Type == "text" {
|
||||
text += block.Text
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
|
||||
}
|
||||
return aiChatResult{
|
||||
Text: strings.TrimSpace(text),
|
||||
PromptTokens: payload.Usage.InputTokens,
|
||||
CompletionTokens: payload.Usage.OutputTokens,
|
||||
FinishReason: payload.StopReason,
|
||||
RequestID: payload.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type geminiProvider struct {
|
||||
model aiModel
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p geminiProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
|
||||
contents := make([]map[string]any, 0, len(req.Messages))
|
||||
for _, message := range req.Messages {
|
||||
role := "user"
|
||||
if message.Role == "assistant" {
|
||||
role = "model"
|
||||
}
|
||||
contents = append(contents, map[string]any{
|
||||
"role": role,
|
||||
"parts": []map[string]string{{"text": message.Text}},
|
||||
})
|
||||
}
|
||||
payloadBody := map[string]any{
|
||||
"contents": contents,
|
||||
"generationConfig": map[string]any{
|
||||
"temperature": aiRequestTemperature(req, p.model),
|
||||
"maxOutputTokens": aiRequestMaxTokens(req, p.model),
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(req.System) != "" {
|
||||
payloadBody["systemInstruction"] = map[string]any{"parts": []map[string]string{{"text": req.System}}}
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/v1beta/models/%s:generateContent", strings.TrimRight(p.base.String(), "/"), url.PathEscape(p.model.ModelName))
|
||||
// The key travels in a header rather than the query string so it stays out
|
||||
// of proxy logs; Gemini accepts both.
|
||||
raw, err := aiPostJSON(ctx, p.client, endpoint, map[string]string{"x-goog-api-key": p.model.APIKey}, payloadBody)
|
||||
if err != nil {
|
||||
return aiChatResult{}, err
|
||||
}
|
||||
var payload struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &payload); err != nil {
|
||||
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON:%s", aiErrorSnippet(raw))
|
||||
}
|
||||
if payload.Error.Message != "" {
|
||||
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
|
||||
}
|
||||
if len(payload.Candidates) == 0 {
|
||||
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
|
||||
}
|
||||
text := ""
|
||||
for _, part := range payload.Candidates[0].Content.Parts {
|
||||
text += part.Text
|
||||
}
|
||||
return aiChatResult{
|
||||
Text: strings.TrimSpace(text),
|
||||
PromptTokens: payload.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: payload.UsageMetadata.CandidatesTokenCount,
|
||||
FinishReason: payload.Candidates[0].FinishReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Orchestration platforms (Coze, Dify, self-hosted flows) each have their own
|
||||
// shape, so they get one normalised contract instead of one adapter each.
|
||||
type webhookAIProvider struct {
|
||||
model aiModel
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p webhookAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
|
||||
messages := make([]map[string]string, 0, len(req.Messages))
|
||||
for _, message := range req.Messages {
|
||||
messages = append(messages, map[string]string{"role": message.Role, "content": message.Text})
|
||||
}
|
||||
headers := map[string]string{}
|
||||
if strings.TrimSpace(p.model.APIKey) != "" {
|
||||
headers["Authorization"] = "Bearer " + p.model.APIKey
|
||||
}
|
||||
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, ""), headers, map[string]any{
|
||||
"model": p.model.ModelName,
|
||||
"system": req.System,
|
||||
"messages": messages,
|
||||
"maxTokens": aiRequestMaxTokens(req, p.model),
|
||||
"temperature": aiRequestTemperature(req, p.model),
|
||||
})
|
||||
if err != nil {
|
||||
return aiChatResult{}, err
|
||||
}
|
||||
var payload struct {
|
||||
Text string `json:"text"`
|
||||
Reply string `json:"reply"`
|
||||
Content string `json:"content"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
} `json:"usage"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &payload); err != nil {
|
||||
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON:%s", aiErrorSnippet(raw))
|
||||
}
|
||||
if payload.Error != "" {
|
||||
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error)
|
||||
}
|
||||
text := payload.Text
|
||||
if text == "" {
|
||||
text = payload.Reply
|
||||
}
|
||||
if text == "" {
|
||||
text = payload.Content
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return aiChatResult{}, fmt.Errorf("Webhook 必须返回 text 字段")
|
||||
}
|
||||
return aiChatResult{
|
||||
Text: strings.TrimSpace(text),
|
||||
PromptTokens: payload.Usage.PromptTokens,
|
||||
CompletionTokens: payload.Usage.CompletionTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deterministic and offline: local development and tests exercise the whole
|
||||
// pipeline without credentials or network access.
|
||||
type debugAIProvider struct{}
|
||||
|
||||
func (debugAIProvider) Chat(_ context.Context, req aiChatRequest) (aiChatResult, error) {
|
||||
last := ""
|
||||
for index := len(req.Messages) - 1; index >= 0; index-- {
|
||||
if req.Messages[index].Role == "user" {
|
||||
last = strings.TrimSpace(req.Messages[index].Text)
|
||||
break
|
||||
}
|
||||
}
|
||||
reply := "在的,刚看到消息"
|
||||
if last != "" {
|
||||
runes := []rune(last)
|
||||
if len(runes) > 12 {
|
||||
runes = runes[:12]
|
||||
}
|
||||
reply = "收到:" + string(runes)
|
||||
}
|
||||
return aiChatResult{Text: reply, PromptTokens: len([]rune(last)), CompletionTokens: len([]rune(reply)), FinishReason: "stop"}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user