后端接入 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,439 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
aiHistoryMessages = 20
|
||||
aiReplyMaxRunes = 120
|
||||
aiJobMaxAttempts = 2
|
||||
// A burst of incoming messages collapses into one reply, but the reader
|
||||
// should not wait longer than this for it.
|
||||
aiReplyMaxDelaySeconds = 15
|
||||
)
|
||||
|
||||
type aiAgentBinding struct {
|
||||
UserID int64
|
||||
ModelID int64
|
||||
Persona string
|
||||
DailyReplyLimit int
|
||||
Nickname string
|
||||
Age int
|
||||
City string
|
||||
Bio string
|
||||
}
|
||||
|
||||
type aiReplyJob struct {
|
||||
ID int64
|
||||
ConversationID int64
|
||||
AgentUserID int64
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// Called right after a message is committed, from every send path. It must stay
|
||||
// cheap and must never fail the send: a missing reply is better than a failed
|
||||
// message.
|
||||
func (a *App) enqueueAIReply(ctx context.Context, conversationID, senderID int64, members []int64, messageID int64) {
|
||||
if !a.configBool(ctx, "ai.enabled", false) {
|
||||
return
|
||||
}
|
||||
peerID := int64(0)
|
||||
for _, member := range members {
|
||||
if member != senderID {
|
||||
if peerID != 0 {
|
||||
return // Group conversations have no single managed recipient.
|
||||
}
|
||||
peerID = member
|
||||
}
|
||||
}
|
||||
if peerID == 0 {
|
||||
return
|
||||
}
|
||||
var agentUserID int64
|
||||
var testBatch string
|
||||
// The sender must not itself be managed, otherwise two agents would keep
|
||||
// each other talking forever.
|
||||
err := a.db.QueryRowContext(ctx, `SELECT g.user_id,u.test_batch
|
||||
FROM ai_agents g JOIN users u ON u.id=g.user_id
|
||||
WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL
|
||||
AND NOT EXISTS(SELECT 1 FROM ai_agents s WHERE s.user_id=? AND s.enabled=1)`,
|
||||
peerID, senderID).Scan(&agentUserID, &testBatch)
|
||||
if err != nil || agentUserID == 0 {
|
||||
return
|
||||
}
|
||||
if !aiBatchAllowed(a.configPlain(ctx, "ai.allow_batches", ""), testBatch) {
|
||||
return
|
||||
}
|
||||
delay := a.aiReplyDelay(ctx)
|
||||
if _, err = a.db.ExecContext(ctx, `INSERT INTO ai_reply_jobs(conversation_id,agent_user_id,trigger_message_id,run_after)
|
||||
VALUES(?,?,?,DATE_ADD(NOW(3),INTERVAL ? MICROSECOND))
|
||||
ON DUPLICATE KEY UPDATE trigger_message_id=VALUES(trigger_message_id),
|
||||
run_after=LEAST(DATE_ADD(created_at,INTERVAL ? SECOND),VALUES(run_after))`,
|
||||
conversationID, agentUserID, messageID, delay.Microseconds(), aiReplyMaxDelaySeconds); err != nil {
|
||||
log.Printf("ai: 入队失败 conversation=%d: %v", conversationID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty allow list means every test account; otherwise the account's batch
|
||||
// has to be named explicitly.
|
||||
func aiBatchAllowed(allowed, batch string) bool {
|
||||
allowed = strings.TrimSpace(allowed)
|
||||
if allowed == "" {
|
||||
return true
|
||||
}
|
||||
for _, item := range strings.Split(allowed, ",") {
|
||||
if strings.TrimSpace(item) == strings.TrimSpace(batch) && batch != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) aiReplyDelay(ctx context.Context) time.Duration {
|
||||
minDelay := a.configInt(ctx, "ai.reply_delay_min_ms", 2000)
|
||||
maxDelay := a.configInt(ctx, "ai.reply_delay_max_ms", 6000)
|
||||
if minDelay < 0 {
|
||||
minDelay = 0
|
||||
}
|
||||
if maxDelay <= minDelay {
|
||||
maxDelay = minDelay
|
||||
}
|
||||
spread := maxDelay - minDelay
|
||||
if spread > 0 {
|
||||
minDelay += rand.Intn(spread)
|
||||
}
|
||||
return time.Duration(minDelay) * time.Millisecond
|
||||
}
|
||||
|
||||
// The worker follows the same ticker shape as processDueAccountClosures.
|
||||
func (a *App) runAIReplyWorker(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.processDueAIReplies(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processDueAIReplies(ctx context.Context) {
|
||||
if !a.configBool(ctx, "ai.enabled", false) {
|
||||
return
|
||||
}
|
||||
concurrency := a.configInt(ctx, "ai.max_concurrency", 4)
|
||||
if concurrency < 1 || concurrency > 64 {
|
||||
concurrency = 4
|
||||
}
|
||||
jobs, err := a.claimAIReplyJobs(ctx, concurrency)
|
||||
if err != nil || len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
var wait sync.WaitGroup
|
||||
for _, job := range jobs {
|
||||
wait.Add(1)
|
||||
go func(job aiReplyJob) {
|
||||
defer wait.Done()
|
||||
if replyErr := a.processAIReplyJob(ctx, job); replyErr != nil {
|
||||
a.failAIReplyJob(ctx, job, replyErr)
|
||||
return
|
||||
}
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=2,last_error='' WHERE id=?`, job.ID)
|
||||
}(job)
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
|
||||
// Claiming stamps a token with a single UPDATE and reads the rows back: each
|
||||
// row can only leave status 0 once, so two instances never take the same job.
|
||||
// SELECT ... FOR UPDATE SKIP LOCKED would be the obvious alternative, but it
|
||||
// does not parse on MySQL 5.7, which some deployments still run.
|
||||
func (a *App) claimAIReplyJobs(ctx context.Context, limit int) ([]aiReplyJob, error) {
|
||||
token := randomToken()[:32]
|
||||
result, err := a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=1,attempts=attempts+1,claim_token=?
|
||||
WHERE status=0 AND run_after<=NOW(3) ORDER BY run_after LIMIT ?`, token, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,conversation_id,agent_user_id,attempts FROM ai_reply_jobs WHERE claim_token=? AND status=1`, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
jobs := []aiReplyJob{}
|
||||
for rows.Next() {
|
||||
var job aiReplyJob
|
||||
if rows.Scan(&job.ID, &job.ConversationID, &job.AgentUserID, &job.Attempts) == nil {
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
}
|
||||
return jobs, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) failAIReplyJob(ctx context.Context, job aiReplyJob, cause error) {
|
||||
message := cause.Error()
|
||||
if len([]rune(message)) > 500 {
|
||||
message = string([]rune(message)[:500])
|
||||
}
|
||||
if job.Attempts >= aiJobMaxAttempts {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error=? WHERE id=?`, message, job.ID)
|
||||
return
|
||||
}
|
||||
// Returning the job to the queue can collide with a newer pending job for
|
||||
// the same conversation; that newer job supersedes this one.
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=0,claim_token='',last_error=?,run_after=DATE_ADD(NOW(3),INTERVAL 5 SECOND) WHERE id=?`, message, job.ID); err != nil {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE ai_reply_jobs SET status=3,last_error=? WHERE id=?`, message, job.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processAIReplyJob(ctx context.Context, job aiReplyJob) error {
|
||||
binding, err := a.loadAIAgent(ctx, job.AgentUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = a.checkAIQuota(ctx, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
history, lastSenderID, err := a.aiConversationHistory(ctx, job.ConversationID, job.AgentUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(history) == 0 || lastSenderID == job.AgentUserID {
|
||||
// Someone already answered, or there is nothing to answer.
|
||||
return nil
|
||||
}
|
||||
model, err := a.aiModelForAgent(ctx, binding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provider, err := newAIProvider(model, a.config.Environment == "production")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
first, err := a.aiFirstReply(ctx, job.ConversationID, job.AgentUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request := aiChatRequest{
|
||||
System: aiSystemPrompt(binding, a.configBool(ctx, "ai.disclose_in_chat", true)),
|
||||
Messages: history,
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, time.Duration(model.TimeoutMS+2000)*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
result, callErr := provider.Chat(callCtx, request)
|
||||
latency := int(time.Since(started).Milliseconds())
|
||||
a.logAICall(ctx, model.ID, "chat", job.AgentUserID, job.ConversationID, latency, result, callErr)
|
||||
if callErr != nil {
|
||||
if fallback := strings.TrimSpace(a.configPlain(ctx, "ai.fallback_text", "")); fallback != "" {
|
||||
_ = a.sendAIReply(ctx, job, fallback)
|
||||
}
|
||||
return callErr
|
||||
}
|
||||
text := sanitizeAIReply(result.Text)
|
||||
if text == "" {
|
||||
return fmt.Errorf("模型返回内容为空")
|
||||
}
|
||||
if first && a.configBool(ctx, "ai.disclose_in_chat", true) {
|
||||
text = "[AI 助手] " + text
|
||||
}
|
||||
if err = a.sendAIReply(ctx, job, text); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE ai_agents SET reply_count=reply_count+1,last_reply_at=NOW(3) WHERE user_id=?`, job.AgentUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) sendAIReply(ctx context.Context, job aiReplyJob, text string) error {
|
||||
item, members, err := a.persistMessageContext(ctx, job.ConversationID, job.AgentUserID,
|
||||
"ai-"+randomToken()[:20], 1, map[string]any{"text": text})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The reply reaches clients through the same push the sender's own messages
|
||||
// use, so unread counts and conversation ordering need no special casing.
|
||||
a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) loadAIAgent(ctx context.Context, userID int64) (aiAgentBinding, error) {
|
||||
binding := aiAgentBinding{UserID: userID}
|
||||
var birthday sql.NullTime
|
||||
err := a.db.QueryRowContext(ctx, `SELECT g.model_id,g.persona,g.daily_reply_limit,p.nickname,p.birthday,p.city_name,p.bio
|
||||
FROM ai_agents g JOIN user_profiles p ON p.user_id=g.user_id JOIN users u ON u.id=g.user_id
|
||||
WHERE g.user_id=? AND g.enabled=1 AND u.is_test=1 AND u.status=1 AND u.deleted_at IS NULL`, userID).
|
||||
Scan(&binding.ModelID, &binding.Persona, &binding.DailyReplyLimit, &binding.Nickname, &birthday, &binding.City, &binding.Bio)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return binding, fmt.Errorf("账号未开启 AI 托管")
|
||||
}
|
||||
if birthday.Valid {
|
||||
binding.Age = age(birthday.Time)
|
||||
}
|
||||
return binding, err
|
||||
}
|
||||
|
||||
func (a *App) aiModelForAgent(ctx context.Context, binding aiAgentBinding) (aiModel, error) {
|
||||
if binding.ModelID > 0 {
|
||||
model, err := a.loadAIModel(ctx, binding.ModelID)
|
||||
if err == nil && model.Status == 1 {
|
||||
return model, nil
|
||||
}
|
||||
}
|
||||
return a.defaultAIModel(ctx)
|
||||
}
|
||||
|
||||
// Quotas are counted from the call log so a restart cannot reset them.
|
||||
func (a *App) checkAIQuota(ctx context.Context, binding aiAgentBinding) error {
|
||||
if limit := a.configInt(ctx, "ai.daily_call_limit", 0); limit > 0 {
|
||||
var used int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ai_call_logs WHERE scene='chat' AND created_at>=CURRENT_DATE()`).Scan(&used)
|
||||
if used >= limit {
|
||||
return fmt.Errorf("已达到今日全局调用上限 %d", limit)
|
||||
}
|
||||
}
|
||||
limit := binding.DailyReplyLimit
|
||||
if limit <= 0 {
|
||||
limit = a.configInt(ctx, "ai.agent_daily_reply_limit", 0)
|
||||
}
|
||||
if limit > 0 {
|
||||
var used int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ai_call_logs WHERE scene='chat' AND agent_user_id=? AND status=1 AND created_at>=CURRENT_DATE()`, binding.UserID).Scan(&used)
|
||||
if used >= limit {
|
||||
return fmt.Errorf("账号已达到今日回复上限 %d", limit)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) aiFirstReply(ctx context.Context, conversationID, agentUserID int64) (bool, error) {
|
||||
var replied int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM im_messages WHERE conversation_id=? AND sender_id=?)`, conversationID, agentUserID).Scan(&replied)
|
||||
return replied == 0, err
|
||||
}
|
||||
|
||||
// Only the tail of the conversation is sent: enough for continuity, bounded in
|
||||
// cost. Media turns become placeholders because the model gets text only.
|
||||
func (a *App) aiConversationHistory(ctx context.Context, conversationID, agentUserID int64) ([]aiChatMessage, int64, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT sender_id,message_type,body,recalled_at,admin_removed_at
|
||||
FROM im_messages WHERE conversation_id=? ORDER BY seq DESC LIMIT ?`, conversationID, aiHistoryMessages)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ordered := []aiChatMessage{}
|
||||
lastSenderID := int64(0)
|
||||
for rows.Next() {
|
||||
var senderID int64
|
||||
var messageType int
|
||||
var body []byte
|
||||
var recalledAt, removedAt sql.NullTime
|
||||
if rows.Scan(&senderID, &messageType, &body, &recalledAt, &removedAt) != nil {
|
||||
continue
|
||||
}
|
||||
if lastSenderID == 0 {
|
||||
lastSenderID = senderID
|
||||
}
|
||||
if recalledAt.Valid || removedAt.Valid {
|
||||
continue
|
||||
}
|
||||
text := aiMessageText(messageType, body)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
role := "user"
|
||||
if senderID == agentUserID {
|
||||
role = "assistant"
|
||||
}
|
||||
ordered = append([]aiChatMessage{{Role: role, Text: text}}, ordered...)
|
||||
}
|
||||
return ordered, lastSenderID, rows.Err()
|
||||
}
|
||||
|
||||
func aiMessageText(messageType int, body []byte) string {
|
||||
switch messageType {
|
||||
case 2:
|
||||
return "[图片]"
|
||||
case 3:
|
||||
return "[语音]"
|
||||
}
|
||||
var content struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if json.Unmarshal(body, &content) != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(content.Text)
|
||||
}
|
||||
|
||||
func aiSystemPrompt(binding aiAgentBinding, disclose bool) string {
|
||||
persona := strings.TrimSpace(binding.Persona)
|
||||
if persona == "" {
|
||||
// Falling back to the profile keeps the replies consistent with what the
|
||||
// other person can see on the page.
|
||||
details := []string{}
|
||||
if binding.Nickname != "" {
|
||||
details = append(details, "昵称"+binding.Nickname)
|
||||
}
|
||||
if binding.Age > 0 {
|
||||
details = append(details, fmt.Sprintf("%d 岁", binding.Age))
|
||||
}
|
||||
if binding.City != "" {
|
||||
details = append(details, "在"+binding.City)
|
||||
}
|
||||
if bio := strings.TrimSpace(binding.Bio); bio != "" {
|
||||
details = append(details, "个人简介:"+bio)
|
||||
}
|
||||
persona = "你是一位社交软件用户," + strings.Join(details, ",") + "。"
|
||||
}
|
||||
rules := []string{
|
||||
"用中文口语回复,像真人聊天一样简短自然,不超过 40 个字。",
|
||||
"一次只说一件事,不要分点、不要 Markdown、不要堆叠表情。",
|
||||
"不索取或提供手机号、微信等联系方式,不谈金钱、转账、投资。",
|
||||
"不主动约线下见面,不输出链接。",
|
||||
"不知道的事就说不知道,不要编造经历细节。",
|
||||
}
|
||||
if disclose {
|
||||
rules = append(rules, "如果对方询问你是不是机器人或 AI,必须如实说明你是 AI 助手。")
|
||||
}
|
||||
return persona + "\n\n对话要求:\n- " + strings.Join(rules, "\n- ")
|
||||
}
|
||||
|
||||
// Models drift into assistant formatting; the chat bubble wants one plain line.
|
||||
func sanitizeAIReply(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if index := strings.Index(text, "```"); index >= 0 {
|
||||
text = text[:index]
|
||||
}
|
||||
replacer := strings.NewReplacer("**", "", "__", "", "##", "", "> ", "", "\r", "\n")
|
||||
text = replacer.Replace(text)
|
||||
lines := []string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "-*•0123456789. "))
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
text = strings.TrimSpace(strings.Join(lines, " "))
|
||||
if runes := []rune(text); len(runes) > aiReplyMaxRunes {
|
||||
text = strings.TrimSpace(string(runes[:aiReplyMaxRunes]))
|
||||
}
|
||||
return text
|
||||
}
|
||||
Reference in New Issue
Block a user