Version: 0.9.76.dev.260505

后端:
1.阶段 6 agent / memory 服务化收口
- 新增 cmd/agent 独立进程入口,承载 agent zrpc server、agent outbox relay / consumer 和运行时依赖初始化
- 补齐 services/agent/rpc 的 Chat stream 与 conversation meta/list/timeline、schedule-preview、context-stats、schedule-state unary RPC
- 新增 gateway/client/agent 与 shared/contracts/agent,将 /api/v1/agent chat 和非 chat 门面切到 agent zrpc
- 收缩 gateway 本地 AgentService 装配,双 RPC 开关开启时不再初始化本地 agent 编排、LLM、RAG 和 memory reader fallback
- 将 backend/memory 物理迁入 services/memory,私有实现收入 internal,保留 module/model/observe 作为 memory 服务门面
- 调整 memory outbox、memory reader 和 agent 记忆渲染链路的 import 与服务边界,cmd/memory 独占 memory worker / consumer
- 关闭 gateway 侧 agent outbox worker 所有权,agent relay / consumer 由 cmd/agent 独占,gateway 仅保留 HTTP/SSE 门面与迁移期开关回退
- 更新阶段 6 文档,记录 agent / memory 当前切流点、smoke 结果,以及 backend/client 与 gateway/shared 的目录收口口径
This commit is contained in:
Losita
2026-05-05 19:31:39 +08:00
parent d7184b776b
commit 2a96f4c6f9
72 changed files with 2775 additions and 291 deletions

View File

@@ -8,18 +8,30 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
gatewayagent "github.com/LoveLosita/smartflow/backend/gateway/client/agent"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
agentsv "github.com/LoveLosita/smartflow/backend/services/agent/sv"
agentcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/agent"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/spf13/viper"
"gorm.io/gorm"
)
const (
agentChatHeartbeatInterval = 5 * time.Second
agentRPCChatEnabledKey = "agent.rpc.chat.enabled"
agentRPCAPIEnabledKey = "agent.rpc.api.enabled"
)
type AgentHandler struct {
svc *agentsv.AgentService
svc *agentsv.AgentService
rpcClient *gatewayagent.Client
rpcClientMu sync.Mutex
}
// NewAgentHandler 组装 AgentHandler。
@@ -29,6 +41,20 @@ func NewAgentHandler(svc *agentsv.AgentService) *AgentHandler {
}
}
// NewAgentHandlerWithRPC 组装带 agent RPC stream 适配能力的 AgentHandler。
//
// 职责边界:
// 1. HTTP / SSE 协议仍由 Gateway 持有;
// 2. agent RPC 作为 chat stream 与非 chat /agent/* 查询/命令的服务间通道;
// 3. svc 只用于 RPC 开关关闭时的迁移期 fallback当前默认可为 nil
// 4. rpcClient 为空时允许按配置懒加载,避免测试和旧装配必须提前构造 client。
func NewAgentHandlerWithRPC(svc *agentsv.AgentService, rpcClient *gatewayagent.Client) *AgentHandler {
return &AgentHandler{
svc: svc,
rpcClient: rpcClient,
}
}
func writeSSEData(w io.Writer, payload string) error {
_, err := io.WriteString(w, "data: "+payload+"\n\n")
return err
@@ -51,6 +77,13 @@ func mapResumeConfirmAction(action model.AgentResumeAction) string {
}
}
type agentChatStreamEvent struct {
payload string
done bool
errorJSON json.RawMessage
err error
}
func (api *AgentHandler) ChatAgent(c *gin.Context) {
// 1) 设置 SSE 响应头
c.Writer.Header().Set("Content-Type", "text/event-stream")
@@ -103,6 +136,16 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
c.Writer.Header().Set("X-Conversation-ID", conversationID)
userID := c.GetInt("user_id")
if api.useAgentRPCChat() {
api.streamAgentChatByRPC(c, req, userID, conversationID)
return
}
if api.svc == nil {
writeAgentSSEError(c.Writer, errors.New("agent local fallback is disabled"))
flushSSEWriter(c.Writer)
return
}
outChan, errChan := api.svc.AgentChat(c.Request.Context(), req.Message, req.Thinking, req.Model, userID, conversationID, req.Extra)
// 4) 转发 SSE 流
@@ -115,22 +158,7 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
select {
case err, ok := <-errChan:
if ok && err != nil {
// 4.1 统一 SSE 错误体:
// 4.1.1 默认按内部错误输出 message/type
// 4.1.2 若是 respond.Response含业务码额外透传 code便于前端识别 5xxxx 等自定义错误。
errorBody := map[string]any{
"message": err.Error(),
"type": "server_error",
}
var respErr respond.Response
if errors.As(err, &respErr) {
errorBody["code"] = respErr.Status
}
errPayload, _ := json.Marshal(map[string]any{
"error": errorBody,
})
_ = writeSSEData(w, string(errPayload))
_ = writeSSEData(w, "[DONE]")
writeAgentSSEError(w, err)
}
return false
case msg, ok := <-outChan:
@@ -152,6 +180,263 @@ func (api *AgentHandler) ChatAgent(c *gin.Context) {
})
}
func (api *AgentHandler) useAgentRPCChat() bool {
return api != nil && viper.GetBool(agentRPCChatEnabledKey)
}
func (api *AgentHandler) useAgentRPCAPI() bool {
return api != nil && viper.GetBool(agentRPCAPIEnabledKey)
}
// streamAgentChatByRPC 把 agent RPC server-stream 平滑转成前端既有 SSE。
//
// 职责边界:
// 1. Gateway 继续负责 SSE header、心跳和 data 帧写出;
// 2. agent RPC 只负责服务间 chunk stream不暴露 Go channel 给跨进程调用方;
// 3. RPC 建流失败或服务端 error_json 仍按现有 SSE 错误体输出,再追加 [DONE]。
func (api *AgentHandler) streamAgentChatByRPC(c *gin.Context, req model.UserSendMessageRequest, userID int, conversationID string) {
client, err := api.getAgentRPCClient()
if err != nil {
writeAgentSSEError(c.Writer, err)
flushSSEWriter(c.Writer)
return
}
extraJSON, err := json.Marshal(req.Extra)
if err != nil {
writeAgentSSEError(c.Writer, err)
flushSSEWriter(c.Writer)
return
}
stream, err := client.Chat(c.Request.Context(), agentcontracts.ChatRequest{
Message: req.Message,
Thinking: req.Thinking,
Model: req.Model,
UserID: userID,
ConversationID: conversationID,
ExtraJSON: extraJSON,
})
if err != nil {
writeAgentSSEError(c.Writer, err)
flushSSEWriter(c.Writer)
return
}
recvCh := make(chan agentChatStreamEvent, 1)
requestCtx := c.Request.Context()
go func() {
defer close(recvCh)
sendEvent := func(event agentChatStreamEvent) bool {
select {
case recvCh <- event:
return true
case <-requestCtx.Done():
return false
}
}
for {
chunk, recvErr := stream.Recv()
if recvErr != nil {
if errors.Is(recvErr, io.EOF) {
return
}
sendEvent(agentChatStreamEvent{err: recvErr})
return
}
if !sendEvent(agentChatStreamEvent{
payload: chunk.Payload,
done: chunk.Done,
errorJSON: append(json.RawMessage(nil), chunk.ErrorJSON...),
}) {
return
}
if chunk.Done || len(chunk.ErrorJSON) > 0 {
return
}
}
}()
heartbeat := time.NewTicker(agentChatHeartbeatInterval)
defer heartbeat.Stop()
c.Stream(func(w io.Writer) bool {
select {
case event, ok := <-recvCh:
if !ok {
return false
}
if event.err != nil {
writeAgentSSEError(w, event.err)
return false
}
if event.payload != "" {
if err := writeSSEData(w, event.payload); err != nil {
return false
}
}
if len(event.errorJSON) > 0 {
_ = writeSSEData(w, string(normalizeAgentRPCErrorJSON(event.errorJSON)))
_ = writeSSEData(w, "[DONE]")
return false
}
if event.done {
_ = writeSSEData(w, "[DONE]")
return false
}
return true
case <-c.Request.Context().Done():
return false
case <-heartbeat.C:
_, _ = io.WriteString(w, ": ping\n\n")
flushSSEWriter(c.Writer)
return true
}
})
}
func writeAgentSSEError(w io.Writer, err error) {
if err == nil {
return
}
_ = writeSSEData(w, string(buildAgentErrorEnvelopeJSON(errorCodeFromError(err), err.Error(), "server_error")))
_ = writeSSEData(w, "[DONE]")
}
func (api *AgentHandler) getAgentRPCClient() (*gatewayagent.Client, error) {
if api == nil {
return nil, errors.New("agent handler is not initialized")
}
api.rpcClientMu.Lock()
defer api.rpcClientMu.Unlock()
if api.rpcClient != nil {
return api.rpcClient, nil
}
client, err := gatewayagent.NewClient(gatewayagent.ClientConfig{
Endpoints: viper.GetStringSlice("agent.rpc.endpoints"),
Target: viper.GetString("agent.rpc.target"),
Timeout: viper.GetDuration("agent.rpc.timeout"),
})
if err != nil {
return nil, err
}
api.rpcClient = client
return api.rpcClient, nil
}
func normalizeAgentRPCErrorJSON(raw json.RawMessage) json.RawMessage {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
return buildAgentErrorEnvelopeJSON("", "agent rpc service returned empty error payload", "server_error")
}
var payload map[string]any
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return buildAgentErrorEnvelopeJSON("", trimmed, "server_error")
}
if nested, ok := payload["error"].(map[string]any); ok {
return buildAgentErrorEnvelopeJSON(
firstNonEmptyString(stringFromAny(nested["code"]), stringFromAny(nested["status"])),
firstNonEmptyString(stringFromAny(nested["message"]), stringFromAny(nested["info"]), "agent rpc service returned error"),
firstNonEmptyString(stringFromAny(nested["type"]), "server_error"),
)
}
return buildAgentErrorEnvelopeJSON(
firstNonEmptyString(stringFromAny(payload["code"]), stringFromAny(payload["status"])),
firstNonEmptyString(stringFromAny(payload["message"]), stringFromAny(payload["info"]), trimmed),
firstNonEmptyString(stringFromAny(payload["type"]), "server_error"),
)
}
func buildAgentErrorEnvelopeJSON(code string, message string, errorType string) json.RawMessage {
errorBody := map[string]any{
"message": strings.TrimSpace(message),
"type": strings.TrimSpace(errorType),
}
if errorBody["message"] == "" {
errorBody["message"] = "agent stream error"
}
if errorBody["type"] == "" {
errorBody["type"] = "server_error"
}
if trimmedCode := strings.TrimSpace(code); trimmedCode != "" {
errorBody["code"] = trimmedCode
}
payload, err := json.Marshal(map[string]any{"error": errorBody})
if err != nil {
return json.RawMessage(`{"error":{"message":"agent stream error","type":"server_error"}}`)
}
return payload
}
func errorCodeFromError(err error) string {
var respErr respond.Response
if errors.As(err, &respErr) {
return strings.TrimSpace(respErr.Status)
}
return ""
}
func stringFromAny(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case json.Number:
return strings.TrimSpace(typed.String())
case float64:
return strings.TrimSpace(strconv.FormatFloat(typed, 'f', -1, 64))
case float32:
return strings.TrimSpace(strconv.FormatFloat(float64(typed), 'f', -1, 32))
case int:
return strconv.Itoa(typed)
case int32:
return strconv.FormatInt(int64(typed), 10)
case int64:
return strconv.FormatInt(typed, 10)
case uint:
return strconv.FormatUint(uint64(typed), 10)
case uint32:
return strconv.FormatUint(uint64(typed), 10)
case uint64:
return strconv.FormatUint(typed, 10)
default:
return ""
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func flushSSEWriter(w io.Writer) {
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
func writeAgentHTTPError(c *gin.Context, err error) {
if err == nil {
return
}
var respErr respond.Response
if errors.As(err, &respErr) && respErr.Status == respond.ConversationNotFound.Status {
c.JSON(http.StatusNotFound, respErr)
return
}
respond.DealWithError(c, err)
}
// GetConversationMeta 返回单个会话的元信息(标题、消息数、最近消息时间等)。
// 设计说明:
// 1) 该接口用于配合 SSE 聊天链路:标题异步生成后,前端可通过 conversation_id 拉取;
@@ -172,8 +457,31 @@ func (api *AgentHandler) GetConversationMeta(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
if api.useAgentRPCAPI() {
client, err := api.getAgentRPCClient()
if err != nil {
writeAgentHTTPError(c, err)
return
}
meta, err := client.GetConversationMeta(ctx, agentcontracts.ConversationQueryRequest{
UserID: userID,
ConversationID: conversationID,
})
if err != nil {
writeAgentHTTPError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, meta))
return
}
localSvc, ok := api.localAgentService(c)
if !ok {
return
}
// 4. 调 service 查询会话元信息。
meta, err := api.svc.GetConversationMeta(ctx, userID, conversationID)
meta, err := localSvc.GetConversationMeta(ctx, userID, conversationID)
if err != nil {
// 会话不存在或越权访问时返回 404让前端能和“参数格式错误”区分开。
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -241,8 +549,33 @@ func (api *AgentHandler) GetConversationList(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
if api.useAgentRPCAPI() {
client, err := api.getAgentRPCClient()
if err != nil {
writeAgentHTTPError(c, err)
return
}
resp, err := client.GetConversationList(ctx, agentcontracts.ConversationListRequest{
UserID: userID,
Page: page,
PageSize: pageSize,
Status: status,
})
if err != nil {
writeAgentHTTPError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp))
return
}
localSvc, ok := api.localAgentService(c)
if !ok {
return
}
// 5. 调 service 查询并返回统一响应结构。
resp, err := api.svc.GetConversationList(ctx, userID, page, pageSize, status)
resp, err := localSvc.GetConversationList(ctx, userID, page, pageSize, status)
if err != nil {
respond.DealWithError(c, err)
return
@@ -268,7 +601,30 @@ func (api *AgentHandler) GetConversationTimeline(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
timeline, err := api.svc.GetConversationTimeline(ctx, userID, conversationID)
if api.useAgentRPCAPI() {
client, err := api.getAgentRPCClient()
if err != nil {
writeAgentHTTPError(c, err)
return
}
timeline, err := client.GetConversationTimeline(ctx, agentcontracts.ConversationQueryRequest{
UserID: userID,
ConversationID: conversationID,
})
if err != nil {
writeAgentHTTPError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, timeline))
return
}
localSvc, ok := api.localAgentService(c)
if !ok {
return
}
timeline, err := localSvc.GetConversationTimeline(ctx, userID, conversationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, respond.ConversationNotFound)
@@ -302,8 +658,31 @@ func (api *AgentHandler) GetSchedulePlanPreview(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
if api.useAgentRPCAPI() {
client, err := api.getAgentRPCClient()
if err != nil {
writeAgentHTTPError(c, err)
return
}
preview, err := client.GetSchedulePlanPreview(ctx, agentcontracts.ConversationQueryRequest{
UserID: userID,
ConversationID: conversationID,
})
if err != nil {
writeAgentHTTPError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, preview))
return
}
localSvc, ok := api.localAgentService(c)
if !ok {
return
}
// 4. 调 service 查询并返回统一响应结构。
preview, err := api.svc.GetSchedulePlanPreview(ctx, userID, conversationID)
preview, err := localSvc.GetSchedulePlanPreview(ctx, userID, conversationID)
if err != nil {
respond.DealWithError(c, err)
return
@@ -324,7 +703,34 @@ func (api *AgentHandler) GetContextStats(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
defer cancel()
statsJSON, err := api.svc.GetContextStats(ctx, userID, conversationID)
if api.useAgentRPCAPI() {
client, err := api.getAgentRPCClient()
if err != nil {
writeAgentHTTPError(c, err)
return
}
statsJSON, err := client.GetContextStats(ctx, agentcontracts.ConversationQueryRequest{
UserID: userID,
ConversationID: conversationID,
})
if err != nil {
writeAgentHTTPError(c, err)
return
}
if strings.TrimSpace(statsJSON) == "" {
statsJSON = "null"
}
var raw json.RawMessage = json.RawMessage(statsJSON)
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, raw))
return
}
localSvc, ok := api.localAgentService(c)
if !ok {
return
}
statsJSON, err := localSvc.GetContextStats(ctx, userID, conversationID)
if err != nil {
respond.DealWithError(c, err)
return
@@ -373,10 +779,65 @@ func (api *AgentHandler) SaveScheduleState(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
if api.useAgentRPCAPI() {
client, err := api.getAgentRPCClient()
if err != nil {
writeAgentHTTPError(c, err)
return
}
if err := client.SaveScheduleState(ctx, agentcontracts.SaveScheduleStateRequest{
UserID: userID,
ConversationID: conversationID,
Items: toAgentContractScheduleStateItems(req.Items),
}); err != nil {
writeAgentHTTPError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, nil))
return
}
localSvc, ok := api.localAgentService(c)
if !ok {
return
}
// 5. 调用 service 层执行 Load → 应用放置项 → Save。
if err := api.svc.SaveScheduleState(ctx, userID, conversationID, req.Items); err != nil {
if err := localSvc.SaveScheduleState(ctx, userID, conversationID, req.Items); err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, nil))
}
// localAgentService 返回迁移期本地 fallback 服务。
//
// 职责边界:
// 1. 只服务于 RPC 开关关闭时的回退路径;
// 2. 默认 RPC 切流态允许 svc 为 nil因此所有本地调用前必须经过此处
// 3. 缺失时返回 500提示启动配置和运行时装配不一致而不是让 handler panic。
func (api *AgentHandler) localAgentService(c *gin.Context) (*agentsv.AgentService, bool) {
if api != nil && api.svc != nil {
return api.svc, true
}
respond.DealWithError(c, errors.New("agent local fallback is disabled"))
return nil, false
}
func toAgentContractScheduleStateItems(items []model.SaveScheduleStatePlacedItem) []agentcontracts.SaveScheduleStatePlacedItem {
if len(items) == 0 {
return nil
}
result := make([]agentcontracts.SaveScheduleStatePlacedItem, 0, len(items))
for _, item := range items {
result = append(result, agentcontracts.SaveScheduleStatePlacedItem{
TaskItemID: item.TaskItemID,
Week: item.Week,
DayOfWeek: item.DayOfWeek,
StartSection: item.StartSection,
EndSection: item.EndSection,
EmbedCourseEventID: item.EmbedCourseEventID,
})
}
return result
}