@@ -739,21 +739,27 @@ func (h *TaskHandler) writeCursor(wsConn *ws.WebsocketManager, cursor string, ha
739739 })
740740}
741741
742- // TaskTurns 查询任务历史轮次(原始 TaskChunk,向前翻页 )
742+ // TaskTurns 查询任务历史轮次(原始 TaskChunk,双向翻页 )
743743//
744744// @Summary 查询任务历史轮次
745- // @Description 根据 cursor 向前翻页查询任务的历史轮次。limit 为轮次数(非条目数),
746- // @Description limit=2 表示返回 2 轮的完整消息。返回的 chunks 按时间倒序排列(最新在前)。
745+ // @Description 根据 cursor 翻页查询任务的历史轮次。limit 为轮次数(非条目数),
746+ // @Description limit=2 表示返回 2 轮的完整消息。direction=backward(默认)从 cursor 往更早翻,轮间倒序(最新轮在前);
747+ // @Description direction=forward 往更新翻,轮间正序(最旧轮在前);轮内消息始终按时间正序。
748+ // @Description 即响应中最后一轮总是与 next_cursor 相邻的那一轮。前端应以每条 chunk 的 seq 做分组排序,不依赖数组顺序。
749+ // @Description 日志存储为 ClickHouse 时 cursor 即轮次号 seq,可配合 inclusive=true 实现"跳转到第 seq 轮";
750+ // @Description Loki 仅支持 backward 且不支持 inclusive,违反时返回 err-task-rounds-direction-unsupported。
747751// @Description 返回的 user-input.data 统一为 JSON payload 字符串,例如 `{"content":"57un57ut5aSE55CG","attachments":[]}`;content 为用户输入文本的 base64 编码,旧历史裸文本也会按该结构包装返回。
748752// @Tags 【用户】任务管理
749753// @Accept json
750754// @Produce json
751755// @Security MonkeyCodeAIAuth
752- // @Param id query string true "任务 ID"
753- // @Param cursor query string false "分页游标"
754- // @Param limit query int false "轮次数(默认 2,上限 10)"
755- // @Success 200 {object} web.Resp{data=domain.TaskRoundsResp} "成功"
756- // @Failure 500 {object} web.Resp "服务器内部错误"
756+ // @Param id query string true "任务 ID"
757+ // @Param cursor query string false "分页游标(ClickHouse 下即轮次号 seq)"
758+ // @Param limit query int false "轮次数(默认 2,上限 10)"
759+ // @Param direction query string false "翻页方向:backward(默认)/ forward"
760+ // @Param inclusive query bool false "是否包含 cursor 指向的那一轮(跳转定位用)"
761+ // @Success 200 {object} web.Resp{data=domain.TaskRoundsResp} "成功"
762+ // @Failure 500 {object} web.Resp "服务器内部错误"
757763// @Router /api/v1/users/tasks/rounds [get]
758764func (h * TaskHandler ) TaskTurns (c * web.Context , req domain.TaskRoundsReq ) error {
759765 ctx := c .Request ().Context ()
@@ -767,8 +773,17 @@ func (h *TaskHandler) TaskTurns(c *web.Context, req domain.TaskRoundsReq) error
767773
768774 start := time .Unix (task .CreatedAt , 0 )
769775
770- result , err := h .tasklog .QueryTurns (ctx , task .ID , start , req .Cursor , req .Limit , task .LogStore )
776+ opts := tasklog.QueryTurnsOpts {
777+ Cursor : req .Cursor ,
778+ Limit : req .Limit ,
779+ Direction : tasklog .Direction (req .Direction ),
780+ Inclusive : req .Inclusive ,
781+ }
782+ result , err := h .tasklog .QueryTurns (ctx , task .ID , start , opts , task .LogStore )
771783 if err != nil {
784+ if errors .Is (err , tasklog .ErrDirectionUnsupported ) {
785+ return errcode .ErrTaskRoundsDirectionUnsupported .Wrap (err )
786+ }
772787 h .logger .With ("error" , err , "task_id" , task .ID ).ErrorContext (ctx , "failed to query turns" )
773788 return errcode .ErrInternalServer .Wrap (fmt .Errorf ("failed to query turns: %w" , err ))
774789 }
@@ -780,12 +795,21 @@ func (h *TaskHandler) TaskTurns(c *web.Context, req domain.TaskRoundsReq) error
780795 Event : c .Event ,
781796 Kind : c .Kind ,
782797 Timestamp : c .Timestamp ,
798+ Seq : c .TurnSeq ,
783799 Labels : c .Labels ,
784800 })
785801 }
786802
787- // 兼容逻辑:当拉到最老的数据且第一条不是 user-input 时,从 db content 补充
788- if ! result .HasMore && len (chunks ) > 0 && chunks [0 ].Event != "user-input" {
803+ // 兼容逻辑:触达最老一轮且第一条不是 user-input 时,从 db content 补充。
804+ // backward 下 !HasMore 即触达最老;forward 下只有不带 cursor 的第一页才从最老开始。
805+ reachedOldest := false
806+ switch opts .Direction {
807+ case "" , tasklog .DirectionBackward :
808+ reachedOldest = ! result .HasMore
809+ case tasklog .DirectionForward :
810+ reachedOldest = req .Cursor == ""
811+ }
812+ if reachedOldest && len (chunks ) > 0 && chunks [0 ].Event != "user-input" {
789813 contentData := normalizeUserInputData ([]byte (task .Content ))
790814 chunks = append ([]* domain.TaskChunkEntry {{
791815 Data : contentData ,
@@ -810,9 +834,10 @@ func (h *TaskHandler) TaskTurns(c *web.Context, req domain.TaskRoundsReq) error
810834// TaskUserInputs 查询任务的所有 user-input 列表(侧边栏快速跳转用)
811835//
812836// @Summary 查询任务用户输入列表
813- // @Description 查询任务的所有 user-input 消息(正序,最早在前) ,用于聊天页侧边栏快速跳转到指定一轮对话。
837+ // @Description 查询任务的 user-input 消息(倒序,最新在前),根据 cursor 向更早翻页 ,用于聊天页侧边栏快速跳转到指定一轮对话。
814838// @Description 单条返回的 id 形如 `user-input-{timestamp_ns}`,与前端聊天页消息列表中的 `data-message-id` 对齐。
815839// @Description content 已解码为明文,超出 500 字符会截断并将 truncated 置为 true。
840+ // @Description 日志存储为 ClickHouse 时每条带 seq(轮次号),可作为 /rounds 接口的 cursor 配合 inclusive=true 跳转定位;Loki 下无 seq。
816841// @Tags 【用户】任务管理
817842// @Accept json
818843// @Produce json
@@ -843,14 +868,14 @@ func (h *TaskHandler) TaskUserInputs(c *web.Context, req domain.TaskUserInputsRe
843868
844869 items := make ([]* domain.TaskUserInputItem , 0 , len (result .Entries )+ 1 )
845870 for _ , e := range result .Entries {
846- items = append (items , buildTaskUserInputItem (e .Timestamp , e .Data ))
871+ items = append (items , buildTaskUserInputItem (e .Timestamp , e .Data , e . TurnSeq ))
847872 }
848873
849874 // 兼容历史任务:仅当 ClickHouse/Loki 里完全没有 user-input 记录时,才用 task.Content
850875 // 合成一条首条(对齐 /rounds 的兜底语义)。不要根据时间戳启发式判断,因为 task.CreatedAt
851876 // 和实际第一条 user-input 落库时间天然会差几十秒(用户思考 + 输入),会误判出重复。
852877 if req .Cursor == "" && ! result .HasMore && len (items ) == 0 && len (task .Content ) > 0 {
853- synth := buildTaskUserInputItem (taskCreatedAt .UnixNano (), normalizeUserInputData ([]byte (task .Content )))
878+ synth := buildTaskUserInputItem (taskCreatedAt .UnixNano (), normalizeUserInputData ([]byte (task .Content )), 0 )
854879 items = append ([]* domain.TaskUserInputItem {synth }, items ... )
855880 }
856881
@@ -866,7 +891,7 @@ func (h *TaskHandler) TaskUserInputs(c *web.Context, req domain.TaskUserInputsRe
866891
867892const taskUserInputPreviewMaxRunes = 500
868893
869- func buildTaskUserInputItem (timestampNS int64 , data []byte ) * domain.TaskUserInputItem {
894+ func buildTaskUserInputItem (timestampNS int64 , data []byte , turnSeq uint32 ) * domain.TaskUserInputItem {
870895 parsed := parseUserInputData (data )
871896 content , truncated := truncateRunes (string (parsed .Content ), taskUserInputPreviewMaxRunes )
872897 // 截到毫秒边界:WebSocket 推送 chunk 时做了 ns/1e6 整数除(task.go: chunk.Timestamp/1e6),
@@ -878,6 +903,7 @@ func buildTaskUserInputItem(timestampNS int64, data []byte) *domain.TaskUserInpu
878903 Content : content ,
879904 Truncated : truncated ,
880905 Timestamp : alignedNS ,
906+ Seq : turnSeq ,
881907 }
882908}
883909
0 commit comments