agentServices.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. package services
  2. import (
  3. "context"
  4. "errors"
  5. "strings"
  6. "time"
  7. "fwh-toolbox-server/config"
  8. mongoRepo "fwh-toolbox-server/repositories/mongo"
  9. "fwh-toolbox-server/utils/agentserver"
  10. "go.uber.org/zap"
  11. )
  12. // 新 AI 助手(AgentServer):会话归属、按角色选择 Profile、代理组装。
  13. // 设计见 docs/ai-assistant-agentserver/technical-design.md §3,与旧助手(aiChatServices.go)相互独立。
  14. // ErrAgentSessionNotFound 会话不存在、已删除或不属于当前用户
  15. var ErrAgentSessionNotFound = errors.New("会话不存在或无权访问")
  16. // 每个用户列出的会话上限
  17. const agentSessionListLimit = 50
  18. // 标题最大长度(字符)
  19. const agentTitleMaxLen = 100
  20. // AgentSessionItem 会话列表项(返回给前端)
  21. type AgentSessionItem struct {
  22. SessionId string `json:"sessionId"`
  23. Title string `json:"title"`
  24. ProfileId string `json:"profileId"`
  25. CreatedAt int64 `json:"createdAt"`
  26. LastActive int64 `json:"lastActive"`
  27. }
  28. // AgentService 新 AI 助手服务
  29. type AgentService struct {
  30. cfg config.AgentConfig
  31. client *agentserver.Client
  32. proxy *agentserver.Proxy
  33. recorder *agentResourceRecorder
  34. }
  35. // NewAgentService 创建服务并启动子资源索引的异步写入
  36. func NewAgentService(cfg config.AgentConfig) *AgentService {
  37. client := agentserver.NewClient(cfg.ServerURL, cfg.ApiKey)
  38. recorder := newAgentResourceRecorder()
  39. s := &AgentService{cfg: cfg, client: client, recorder: recorder}
  40. s.proxy = &agentserver.Proxy{
  41. Client: client,
  42. Guard: &agentserver.Guard{Store: agentMongoStore{}, Upstream: client, Sink: recorder},
  43. Sink: recorder,
  44. OnMessage: func(userId, sessionId string) {
  45. go func() {
  46. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  47. defer cancel()
  48. if err := mongoRepo.TouchAgentSession(ctx, userId, sessionId); err != nil {
  49. zap.L().Warn("[Agent] 刷新会话活跃时间失败", zap.String("sessionId", sessionId), zap.Error(err))
  50. }
  51. }()
  52. },
  53. OnTitle: func(userId, sessionId, title string) {
  54. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  55. defer cancel()
  56. if err := mongoRepo.UpdateAgentSessionTitle(ctx, userId, sessionId, clipTitle(title)); err != nil {
  57. zap.L().Warn("[Agent] 同步会话标题失败", zap.String("sessionId", sessionId), zap.Error(err))
  58. }
  59. },
  60. }
  61. if cfg.Enabled && !client.Configured() {
  62. zap.L().Warn("[Agent] 已开启新 AI 助手,但 AGENT_SERVER_URL / AGENT_SERVER_API_KEY 未配置")
  63. }
  64. return s
  65. }
  66. // Enabled 新助手对该用户是否可用:总开关打开、已配置 AgentServer、角色允许
  67. func (s *AgentService) Enabled(roles []string) bool {
  68. return s.cfg.Enabled && s.client.Configured() && agentserver.RoleAllowed(roles, s.cfg.AllowedRoles)
  69. }
  70. // Proxy /agent/v1/* 代理
  71. func (s *AgentService) Proxy() *agentserver.Proxy {
  72. return s.proxy
  73. }
  74. // ListSessions 本人未删除的会话,按最近活跃倒序
  75. func (s *AgentService) ListSessions(ctx context.Context, userId string) ([]AgentSessionItem, error) {
  76. list, err := mongoRepo.ListAgentSessionsByUser(ctx, userId, agentSessionListLimit)
  77. if err != nil {
  78. return nil, err
  79. }
  80. out := make([]AgentSessionItem, 0, len(list))
  81. for _, r := range list {
  82. out = append(out, toAgentSessionItem(r))
  83. }
  84. return out, nil
  85. }
  86. // CreateSession 按角色选择 Profile,在 AgentServer 创建会话并记录归属
  87. func (s *AgentService) CreateSession(ctx context.Context, userId string, roles []string) (*AgentSessionItem, error) {
  88. profileId := agentserver.SelectProfile(roles, s.cfg.ProfileByRole, s.cfg.ProfileDefault)
  89. dto, err := s.client.CreateSession(ctx, profileId)
  90. if err != nil {
  91. return nil, err
  92. }
  93. if dto.ProfileId != nil && *dto.ProfileId != "" {
  94. profileId = *dto.ProfileId
  95. }
  96. now := time.Now()
  97. rec := mongoRepo.AgentSession{
  98. SessionId: dto.Id,
  99. UserId: userId,
  100. ProfileId: profileId,
  101. Title: dto.Title,
  102. CreatedAt: now,
  103. LastActive: now,
  104. }
  105. if err := mongoRepo.InsertAgentSession(ctx, rec); err != nil {
  106. // 归属写入失败时删除上游会话,避免出现无主会话
  107. if delErr := s.client.DeleteSession(context.Background(), dto.Id); delErr != nil {
  108. zap.L().Warn("[Agent] 回滚上游会话失败", zap.String("sessionId", dto.Id), zap.Error(delErr))
  109. }
  110. return nil, err
  111. }
  112. zap.L().Info("[Agent] 创建会话", zap.String("userId", userId), zap.String("sessionId", dto.Id), zap.String("profileId", profileId))
  113. item := toAgentSessionItem(rec)
  114. return &item, nil
  115. }
  116. // RenameSession 修改标题
  117. func (s *AgentService) RenameSession(ctx context.Context, userId, sessionId, title string) error {
  118. if err := s.ensureOwned(ctx, userId, sessionId); err != nil {
  119. return err
  120. }
  121. title = clipTitle(title)
  122. if err := s.client.PatchSession(ctx, sessionId, map[string]any{"title": title}); err != nil {
  123. return err
  124. }
  125. return mongoRepo.UpdateAgentSessionTitle(ctx, userId, sessionId, title)
  126. }
  127. // DeleteSession 删除上游会话并软删除归属记录
  128. func (s *AgentService) DeleteSession(ctx context.Context, userId, sessionId string) error {
  129. if err := s.ensureOwned(ctx, userId, sessionId); err != nil {
  130. return err
  131. }
  132. if err := s.client.DeleteSession(ctx, sessionId); err != nil {
  133. return err
  134. }
  135. return mongoRepo.SoftDeleteAgentSession(ctx, userId, sessionId)
  136. }
  137. func (s *AgentService) ensureOwned(ctx context.Context, userId, sessionId string) error {
  138. rec, err := mongoRepo.GetAgentSession(ctx, sessionId)
  139. if err != nil {
  140. return err
  141. }
  142. if rec == nil || rec.DeletedAt != nil || rec.UserId != userId {
  143. return ErrAgentSessionNotFound
  144. }
  145. return nil
  146. }
  147. func toAgentSessionItem(r mongoRepo.AgentSession) AgentSessionItem {
  148. return AgentSessionItem{
  149. SessionId: r.SessionId,
  150. Title: r.Title,
  151. ProfileId: r.ProfileId,
  152. CreatedAt: r.CreatedAt.UnixMilli(),
  153. LastActive: r.LastActive.UnixMilli(),
  154. }
  155. }
  156. func clipTitle(t string) string {
  157. t = strings.TrimSpace(t)
  158. if r := []rune(t); len(r) > agentTitleMaxLen {
  159. return string(r[:agentTitleMaxLen])
  160. }
  161. return t
  162. }
  163. // ============ 归属存储(Mongo) ============
  164. type agentMongoStore struct{}
  165. func (agentMongoStore) SessionOwner(ctx context.Context, sessionId string) (string, bool, error) {
  166. rec, err := mongoRepo.GetAgentSession(ctx, sessionId)
  167. if err != nil || rec == nil || rec.DeletedAt != nil {
  168. return "", false, err
  169. }
  170. return rec.UserId, true, nil
  171. }
  172. func (agentMongoStore) ResourceSession(ctx context.Context, resourceId string) (string, bool, error) {
  173. sid, err := mongoRepo.GetAgentResourceSession(ctx, resourceId)
  174. if err != nil {
  175. return "", false, err
  176. }
  177. return sid, sid != "", nil
  178. }
  179. func (agentMongoStore) RecentSessionIds(ctx context.Context, userId string, limit int) ([]string, error) {
  180. list, err := mongoRepo.ListAgentSessionsByUser(ctx, userId, int64(limit))
  181. if err != nil {
  182. return nil, err
  183. }
  184. ids := make([]string, 0, len(list))
  185. for _, r := range list {
  186. ids = append(ids, r.SessionId)
  187. }
  188. return ids, nil
  189. }
  190. // ============ 子资源索引的异步批量写入 ============
  191. const (
  192. agentRecorderQueue = 2048
  193. agentRecorderBatch = 200
  194. agentRecorderInterval = 500 * time.Millisecond
  195. )
  196. // agentResourceRecorder 代理转发时解析到的子资源 ID 先进队列,后台按批写入,不阻塞转发
  197. type agentResourceRecorder struct {
  198. ch chan agentserver.Resource
  199. }
  200. func newAgentResourceRecorder() *agentResourceRecorder {
  201. r := &agentResourceRecorder{ch: make(chan agentserver.Resource, agentRecorderQueue)}
  202. go r.loop()
  203. return r
  204. }
  205. // Record 实现 agentserver.ResourceSink;队列满时丢弃(之后由归属反查补写)
  206. func (r *agentResourceRecorder) Record(rs []agentserver.Resource) {
  207. for _, res := range rs {
  208. select {
  209. case r.ch <- res:
  210. default:
  211. zap.L().Warn("[Agent] 子资源索引队列已满,丢弃", zap.String("resourceId", res.Id))
  212. }
  213. }
  214. }
  215. func (r *agentResourceRecorder) loop() {
  216. ticker := time.NewTicker(agentRecorderInterval)
  217. defer ticker.Stop()
  218. pending := map[string]string{}
  219. flush := func() {
  220. if len(pending) == 0 {
  221. return
  222. }
  223. now := time.Now()
  224. batch := make([]mongoRepo.AgentResource, 0, len(pending))
  225. for id, sid := range pending {
  226. batch = append(batch, mongoRepo.AgentResource{ResourceId: id, SessionId: sid, CreatedAt: now})
  227. }
  228. pending = map[string]string{}
  229. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  230. defer cancel()
  231. if err := mongoRepo.UpsertAgentResources(ctx, batch); err != nil {
  232. zap.L().Warn("[Agent] 写入子资源索引失败", zap.Int("count", len(batch)), zap.Error(err))
  233. }
  234. }
  235. for {
  236. select {
  237. case res := <-r.ch:
  238. pending[res.Id] = res.SessionId
  239. if len(pending) >= agentRecorderBatch {
  240. flush()
  241. }
  242. case <-ticker.C:
  243. flush()
  244. }
  245. }
  246. }