topfans/backend/services/aiChatService/service/dify_client.go
Lenticular Studio Agent 65ce6bba12 feat: Dify 部署脚本修复 + AI 搭子 MVP 接入
主要改动:

fix(docker/dify-deploy): 修复脚本核心功能
- heredoc 单引号 bug: 'ENVEOF' 改为 ENVEOF,变量正确展开
- 端口默认值 8083/8084/8085 对齐 .env.prod 生产配置
- 加 dc_cmd() 兼容 docker-compose v1/v2 plugin
- openssl rand 生成强随机密码与 SECRET_KEY(42 字符)
- install 跳过已存在 .env,保护用户配置(管理员密码/SECRET_KEY)
- read -p < /dev/tty 兼容非 tty 环境(CI/CD)
- show-config 改用 DIFY_NGINX_PORT(nginx 入口)而非 APP_WEB_PORT

docs(mvp-design): 修正 §3.2 workflow inputs 描述
- 实际只有 query,删除错误的 user_id input 声明
- 节点序列图同步更新

feat(aiChatService): 新增 Dify 客户端与适配器
- service/dify_client.go: Dify Workflow 调用 + SSE 解析
- service/dify_adapter.go: 与现有 chat_service 桥接
- provider/ai_chat_provider.go: Dubbo 入口简化
- main.go: 装配 ConversationRepository + DifyClient

feat(migrations): 新增 AI 搭子会话表 ai_chat.sql
- ai_conversations / ai_messages 表 + 索引

docs: 新增 Dify 集成设计文档
- 2026-06-29-ai-chat-dify-mvp-design.md (MVP 实施级)
- 2026-06-29-ai-chat-dify-integration-v2-design.md (V2 演进路线图)
- docs/dify/角角.yml (Workflow DSL 导出)

config: 更新 env 模板与 docker 配置
- backend/.env.example: DIFY_* 环境变量声明
- docker/.env.prod: DIFY_API_BASE 对齐 8083
- docker/build.sh: 微调
- CLAUDE.md: 项目规范补充

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 12:32:34 +08:00

214 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/topfans/backend/pkg/logger"
"go.uber.org/zap"
)
// DifyConfig Dify 客户端配置
// MVP 阶段:直接从环境变量读
// Stage 2+:移到 ai_chat_configs 数据库
type DifyConfig struct {
APIBase string
APIKey string
TimeoutSec int
}
// DifyClient Dify HTTP 客户端MVP 唯一 AI 源)
// ★ 简化:只调 Workflow /v1/workflows/run 流式接口
// 不做重试循环V1 Network 错误重试 1 次MVP 失败直接报错)
// 不做滑动窗口审计V1 AuditService 逐 token 拦截已足够)
type DifyClient struct {
apiBase string
apiKey string
httpClient *http.Client
}
// NewDifyClient 创建 Dify 客户端
func NewDifyClient(cfg DifyConfig) *DifyClient {
return &DifyClient{
apiBase: cfg.APIBase,
apiKey: cfg.APIKey,
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
}
}
// StreamRequest 流式调用入参
type StreamRequest struct {
Query string
ConversationID string
}
// StreamChat 流式调用 Dify Workflow
// 输入:用户消息 + Dify conversation_id首次为空
// 输出:返回 (DifyStreamReader, error)
func (c *DifyClient) StreamChat(ctx context.Context, req StreamRequest) (*DifyStreamReader, error) {
body := map[string]interface{}{
"inputs": map[string]string{"query": req.Query},
"response_mode": "streaming",
"conversation_id": req.ConversationID,
"user": "aichat-mvp",
}
jsonData, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("dify request marshal: %w", err)
}
url := c.apiBase + "/workflows/run"
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("dify request new: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("dify request: %w", err)
}
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("dify returned HTTP %d: %s", resp.StatusCode, string(respBody))
}
logger.Logger.Info("Dify stream started", zap.String("url", url))
return newDifyStreamReader(resp.Body), nil
}
// ============================================================
// DifyStreamReader解析 SSE 流
// ============================================================
// newDifyStreamReader 创建流式读取器
func newDifyStreamReader(body io.ReadCloser) *DifyStreamReader {
scanner := bufio.NewScanner(body)
// 增大 buffer 防止 Dify 流式 chunk 过大被截断
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
return &DifyStreamReader{
body: body,
conversationID: "",
closed: false,
scanner: scanner,
}
}
// DifyStreamReader Dify SSE 流式读取器
// 简化版本:用 bufio.Scanner 按行读 data: 字段
type DifyStreamReader struct {
body io.ReadCloser
conversationID string
closed bool
scanner *bufio.Scanner
}
// Next 读下一条数据
// 返回 (content, done, convID)
// - content: 当前 token流式
// - done: 流是否结束
// - convID: 流结束后从 convID 读 Dify conversation_id
func (r *DifyStreamReader) Next() (content string, done bool, convID string) {
if r.closed {
return "", true, r.conversationID
}
for r.scanner.Scan() {
line := strings.TrimSpace(r.scanner.Text())
if line == "" {
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
if payload == "[DONE]" {
r.closed = true
return "", true, r.conversationID
}
// ★ MVP 修复Dify Workflow 端点 (/v1/workflows/run) 的事件格式
// - text_chunk: 流式分块text 字段是内容
// - workflow_finished: 结束事件data.outputs.text 是完整内容
// - 没有 conversation_idWorkflow 端点不维护 conversation_id
// ★ 与 Chatflow 端点 (/v1/chat-messages) 不同:
// - Chatflow 用 event=message, answer 字段
// - Workflow 用 event=text_chunk, text 字段
var event struct {
Event string `json:"event"`
Text string `json:"text"` // Chatflow: 顶层 text
ConversationID string `json:"conversation_id"`
Message string `json:"message"`
Data struct {
Text string `json:"text"` // Workflow: data.text
} `json:"data"`
}
if err := json.Unmarshal([]byte(payload), &event); err != nil {
continue
}
// 记录 conversation_id仅 Chatflow 有Workflow 端点通常无)
if event.ConversationID != "" && r.conversationID == "" {
r.conversationID = event.ConversationID
}
text := event.Data.Text
if text == "" {
text = event.Text
}
switch event.Event {
case "text_chunk":
if text != "" {
return text, false, ""
}
case "message":
if text != "" {
return text, false, ""
}
case "workflow_finished":
// ★ MVP 关键Dify Workflow 结束事件
r.closed = true
return "", true, r.conversationID
case "message_end":
// Chatflow 端点兼容
r.closed = true
return "", true, r.conversationID
case "error":
logger.Logger.Error("Dify stream error event", zap.String("message", event.Message))
r.closed = true
return "", true, r.conversationID
}
}
// scanner 结束(流关闭或出错)
r.closed = true
if err := r.scanner.Err(); err != nil {
logger.Logger.Warn("Dify stream scan error", zap.Error(err))
}
return "", true, r.conversationID
}
// Close 关闭流
func (r *DifyStreamReader) Close() error {
if r.closed {
return nil
}
r.closed = true
return r.body.Close()
}
// GetConversationID 获取 Dify 返回的 conversation_id
// 仅在 Next() 返回 done=true 后调用
func (r *DifyStreamReader) GetConversationID() string {
return r.conversationID
}