Skip to content

Commit 209fe7f

Browse files
authored
Merge pull request #698 from chaitin/feat-rounds-direction
feat: rounds 接口支持 向上/向下 翻页 && user-inputs 向上翻页
2 parents 9cf578c + 1c431d3 commit 209fe7f

16 files changed

Lines changed: 505 additions & 109 deletions

backend/biz/task/handler/v1/task.go

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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]
758764
func (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

867892
const 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

backend/biz/task/service/tasksummary.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type TaskSummaryService struct {
4444
}
4545

4646
type tasklogGateway interface {
47-
QueryTurns(ctx context.Context, taskID uuid.UUID, taskCreatedAt time.Time, cursor string, limit int, store consts.LogStore) (*tasklog.QueryTurnsResp, error)
47+
QueryTurns(ctx context.Context, taskID uuid.UUID, taskCreatedAt time.Time, opts tasklog.QueryTurnsOpts, store consts.LogStore) (*tasklog.QueryTurnsResp, error)
4848
}
4949

5050
type ConversationReader interface {
@@ -303,7 +303,7 @@ func (r *tasklogConversationReader) Fetch(ctx context.Context, taskID uuid.UUID,
303303
cursor := ""
304304

305305
for {
306-
resp, err := r.gateway.QueryTurns(ctx, taskID, createdAt, cursor, pageSize, store)
306+
resp, err := r.gateway.QueryTurns(ctx, taskID, createdAt, tasklog.QueryTurnsOpts{Cursor: cursor, Limit: pageSize}, store)
307307
if err != nil {
308308
return nil, fmt.Errorf("failed to fetch task log history: %w", err)
309309
}

backend/biz/task/service/tasksummary_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type fakeTasklogGateway struct {
2222
calls int
2323
}
2424

25-
func (f *fakeTasklogGateway) QueryTurns(ctx context.Context, taskID uuid.UUID, taskCreatedAt time.Time, cursor string, limit int, store consts.LogStore) (*tasklog.QueryTurnsResp, error) {
25+
func (f *fakeTasklogGateway) QueryTurns(ctx context.Context, taskID uuid.UUID, taskCreatedAt time.Time, opts tasklog.QueryTurnsOpts, store consts.LogStore) (*tasklog.QueryTurnsResp, error) {
2626
f.calls++
2727
f.stores = append(f.stores, store)
2828
if len(f.responses) == 0 {

backend/docs/swagger.json

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2635,6 +2635,53 @@
26352635
}
26362636
}
26372637
}
2638+
},
2639+
"delete": {
2640+
"security": [
2641+
{
2642+
"MonkeyCodeAITeamAuth": []
2643+
}
2644+
],
2645+
"description": "软删除团队成员或管理员,不删除团队成员关系",
2646+
"consumes": [
2647+
"application/json"
2648+
],
2649+
"produces": [
2650+
"application/json"
2651+
],
2652+
"tags": [
2653+
"【Team 管理员】分组成员管理"
2654+
],
2655+
"summary": "删除团队成员",
2656+
"parameters": [
2657+
{
2658+
"type": "string",
2659+
"description": "用户ID",
2660+
"name": "user_id",
2661+
"in": "path",
2662+
"required": true
2663+
}
2664+
],
2665+
"responses": {
2666+
"200": {
2667+
"description": "成功",
2668+
"schema": {
2669+
"$ref": "#/definitions/web.Resp"
2670+
}
2671+
},
2672+
"401": {
2673+
"description": "未授权",
2674+
"schema": {
2675+
"$ref": "#/definitions/web.Resp"
2676+
}
2677+
},
2678+
"500": {
2679+
"description": "服务器内部错误",
2680+
"schema": {
2681+
"$ref": "#/definitions/web.Resp"
2682+
}
2683+
}
2684+
}
26382685
}
26392686
},
26402687
"/api/v1/teams/users/{user_id}/passwords/reset": {
@@ -8277,7 +8324,7 @@
82778324
"MonkeyCodeAIAuth": []
82788325
}
82798326
],
8280-
"description": "根据 cursor 向前翻页查询任务的历史轮次。limit 为轮次数(非条目数),\nlimit=2 表示返回 2 轮的完整消息。返回的 chunks 按时间倒序排列(最新在前)\n返回的 user-input.data 统一为 JSON payload 字符串,例如 `{\"content\":\"57un57ut5aSE55CG\",\"attachments\":[]}`;content 为用户输入文本的 base64 编码,旧历史裸文本也会按该结构包装返回。",
8327+
"description": "根据 cursor 翻页查询任务的历史轮次。limit 为轮次数(非条目数),\nlimit=2 表示返回 2 轮的完整消息。direction=backward(默认)从 cursor 往更早翻,轮间倒序(最新轮在前);\ndirection=forward 往更新翻,轮间正序(最旧轮在前);轮内消息始终按时间正序。\n即响应中最后一轮总是与 next_cursor 相邻的那一轮。前端应以每条 chunk 的 seq 做分组排序,不依赖数组顺序。\n日志存储为 ClickHouse 时 cursor 即轮次号 seq,可配合 inclusive=true 实现\"跳转到第 seq 轮\"\nLoki 仅支持 backward 且不支持 inclusive,违反时返回 err-task-rounds-direction-unsupported\n返回的 user-input.data 统一为 JSON payload 字符串,例如 `{\"content\":\"57un57ut5aSE55CG\",\"attachments\":[]}`;content 为用户输入文本的 base64 编码,旧历史裸文本也会按该结构包装返回。",
82818328
"consumes": [
82828329
"application/json"
82838330
],
@@ -8298,7 +8345,7 @@
82988345
},
82998346
{
83008347
"type": "string",
8301-
"description": "分页游标",
8348+
"description": "分页游标(ClickHouse 下即轮次号 seq)",
83028349
"name": "cursor",
83038350
"in": "query"
83048351
},
@@ -8307,6 +8354,18 @@
83078354
"description": "轮次数(默认 2,上限 10)",
83088355
"name": "limit",
83098356
"in": "query"
8357+
},
8358+
{
8359+
"type": "string",
8360+
"description": "翻页方向:backward(默认)/ forward",
8361+
"name": "direction",
8362+
"in": "query"
8363+
},
8364+
{
8365+
"type": "boolean",
8366+
"description": "是否包含 cursor 指向的那一轮(跳转定位用)",
8367+
"name": "inclusive",
8368+
"in": "query"
83108369
}
83118370
],
83128371
"responses": {
@@ -8516,7 +8575,7 @@
85168575
"MonkeyCodeAIAuth": []
85178576
}
85188577
],
8519-
"description": "查询任务的所有 user-input 消息(正序,最早在前),用于聊天页侧边栏快速跳转到指定一轮对话。\n单条返回的 id 形如 `user-input-{timestamp_ns}`,与前端聊天页消息列表中的 `data-message-id` 对齐。\ncontent 已解码为明文,超出 500 字符会截断并将 truncated 置为 true。",
8578+
"description": "查询任务的 user-input 消息(倒序,最新在前),根据 cursor 向更早翻页,用于聊天页侧边栏快速跳转到指定一轮对话。\n单条返回的 id 形如 `user-input-{timestamp_ns}`,与前端聊天页消息列表中的 `data-message-id` 对齐。\ncontent 已解码为明文,超出 500 字符会截断并将 truncated 置为 true\n日志存储为 ClickHouse 时每条带 seq(轮次号),可作为 /rounds 接口的 cursor 配合 inclusive=true 跳转定位;Loki 下无 seq",
85208579
"consumes": [
85218580
"application/json"
85228581
],
@@ -9262,6 +9321,9 @@
92629321
"domain.AddTeamAdminResp": {
92639322
"type": "object",
92649323
"properties": {
9324+
"password": {
9325+
"type": "string"
9326+
},
92659327
"user": {
92669328
"$ref": "#/definitions/domain.TeamUser"
92679329
}
@@ -11544,6 +11606,10 @@
1154411606
"type": "string"
1154511607
}
1154611608
},
11609+
"seq": {
11610+
"description": "轮次号,可作为 cursor 翻页;仅日志存储为 ClickHouse 时有值",
11611+
"type": "integer"
11612+
},
1154711613
"timestamp": {
1154811614
"type": "integer"
1154911615
}
@@ -11629,6 +11695,10 @@
1162911695
"description": "与前端 message.id 对齐:user-input-{timestamp}",
1163011696
"type": "string"
1163111697
},
11698+
"seq": {
11699+
"description": "轮次号,可作为 /rounds 的 cursor 跳转定位;仅日志存储为 ClickHouse 时有值",
11700+
"type": "integer"
11701+
},
1163211702
"timestamp": {
1163311703
"description": "纳秒,与 chunk.timestamp 对齐",
1163411704
"type": "integer"
@@ -12724,6 +12794,9 @@
1272412794
"properties": {
1272512795
"is_blocked": {
1272612796
"type": "boolean"
12797+
},
12798+
"name": {
12799+
"type": "string"
1272712800
}
1272812801
}
1272912802
},

backend/domain/task.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -326,11 +326,13 @@ type TaskControlReq struct {
326326
ID uuid.UUID `json:"id" query:"id" validate:"required"` // 任务 id
327327
}
328328

329-
// TaskRoundsReq 查询任务历史轮次请求(向前翻页
329+
// TaskRoundsReq 查询任务历史轮次请求(双向翻页
330330
type TaskRoundsReq struct {
331-
ID uuid.UUID `json:"id" query:"id" validate:"required"` // 任务 ID
332-
Cursor string `json:"cursor" query:"cursor"` // 分页游标
333-
Limit int `json:"limit" query:"limit"` // 返回的轮次数(默认 2,上限 10)
331+
ID uuid.UUID `json:"id" query:"id" validate:"required"` // 任务 ID
332+
Cursor string `json:"cursor" query:"cursor"` // 分页游标;日志存储为 ClickHouse 时即轮次号 seq
333+
Limit int `json:"limit" query:"limit"` // 返回的轮次数(默认 2,上限 10)
334+
Direction string `json:"direction" query:"direction" validate:"omitempty,oneof=backward forward"` // 翻页方向,默认 backward(往更早翻);forward 仅 ClickHouse 支持
335+
Inclusive bool `json:"inclusive" query:"inclusive"` // 是否包含 cursor 指向的那一轮(跳转定位用),仅 ClickHouse 支持
334336
}
335337

336338
// TaskRoundsResp 查询任务历史轮次响应
@@ -346,22 +348,24 @@ type TaskChunkEntry struct {
346348
Event string `json:"event"`
347349
Kind string `json:"kind"`
348350
Timestamp int64 `json:"timestamp"`
351+
Seq uint32 `json:"seq,omitempty"` // 轮次号,可作为 cursor 翻页;仅日志存储为 ClickHouse 时有值
349352
Labels map[string]string `json:"labels,omitempty"`
350353
}
351354

352-
// TaskUserInputsReq 查询任务用户输入列表请求(正序,从最早翻到最新
355+
// TaskUserInputsReq 查询任务用户输入列表请求(倒序,从最新翻到最早
353356
type TaskUserInputsReq struct {
354357
ID uuid.UUID `json:"id" query:"id" validate:"required"` // 任务 ID
355-
Cursor string `json:"cursor" query:"cursor"` // 分页游标
358+
Cursor string `json:"cursor" query:"cursor"` // 分页游标,第一页留空;编码上一页最后一条(最早一条)的纳秒时间戳
356359
Limit int `json:"limit" query:"limit"` // 返回条数(默认 20,上限 100)
357360
}
358361

359362
// TaskUserInputItem 单条用户输入(侧边栏用)
360363
type TaskUserInputItem struct {
361-
ID string `json:"id"` // 与前端 message.id 对齐:user-input-{timestamp}
362-
Content string `json:"content"` // 用户输入文本,超过 500 字符截断
363-
Truncated bool `json:"truncated"` // 是否被截断
364-
Timestamp int64 `json:"timestamp"` // 纳秒,与 chunk.timestamp 对齐
364+
ID string `json:"id"` // 与前端 message.id 对齐:user-input-{timestamp}
365+
Content string `json:"content"` // 用户输入文本,超过 500 字符截断
366+
Truncated bool `json:"truncated"` // 是否被截断
367+
Timestamp int64 `json:"timestamp"` // 纳秒,与 chunk.timestamp 对齐
368+
Seq uint32 `json:"seq,omitempty"` // 轮次号,可作为 /rounds 的 cursor 跳转定位;仅日志存储为 ClickHouse 时有值
365369
}
366370

367371
// TaskUserInputsResp 查询任务用户输入列表响应

0 commit comments

Comments
 (0)