主要改动: 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>
214 lines
5.9 KiB
Go
214 lines
5.9 KiB
Go
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_id(Workflow 端点不维护 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
|
||
}
|