fix: 优化 Workbench 会话列表加载
This commit is contained in:
@@ -1349,17 +1349,15 @@ func (s *Server) querySummaryFacts(ctx context.Context, sessions []map[string]an
|
|||||||
sessionIDs := uniqueStrings(mapSessions(sessions, sessionID))
|
sessionIDs := uniqueStrings(mapSessions(sessions, sessionID))
|
||||||
facts := emptyFacts()
|
facts := emptyFacts()
|
||||||
if len(sessionIDs) > 0 {
|
if len(sessionIDs) > 0 {
|
||||||
messageSummaries, err := s.queryMessageSummaries(ctx, sessionIDs)
|
messageSummaries, messages, turns, err := s.querySessionSummaryFacts(ctx, sessionIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
facts = mergeFacts(facts, map[string][]any{"messageSummaries": messageSummaries})
|
facts = mergeFacts(facts, map[string][]any{
|
||||||
|
"messageSummaries": messageSummaries,
|
||||||
bySession, err := s.queryFacts(ctx, factQuery{SessionIDs: sessionIDs, Families: []string{"messages", "turns"}, Limit: summarySessionFactLimit(len(sessionIDs)), MessagesOrder: "updated_desc", TurnsOrder: "updated_desc"})
|
"messages": messages,
|
||||||
if err != nil {
|
"turns": turns,
|
||||||
return nil, err
|
})
|
||||||
}
|
|
||||||
facts = mergeFacts(facts, bySession)
|
|
||||||
}
|
}
|
||||||
traceIDs := summaryTraceIDs(sessions, facts)
|
traceIDs := summaryTraceIDs(sessions, facts)
|
||||||
if len(traceIDs) > 0 {
|
if len(traceIDs) > 0 {
|
||||||
@@ -1372,18 +1370,35 @@ func (s *Server) querySummaryFacts(ctx context.Context, sessions []map[string]an
|
|||||||
return facts, nil
|
return facts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func summarySessionFactLimit(sessionCount int) int {
|
func (s *Server) querySessionSummaryFacts(ctx context.Context, sessionIDs []string) ([]any, []any, []any, error) {
|
||||||
if sessionCount <= 0 {
|
const recentFactsPerSession = 8
|
||||||
return 0
|
var messageSummaries, messages, turns []any
|
||||||
|
var messageSummaryErr, messagesErr, turnsErr error
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
wait.Add(3)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
messageSummaries, messageSummaryErr = s.queryMessageSummaries(ctx, sessionIDs)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
messages, messagesErr = s.queryRecentSessionFacts(ctx, "workbench_messages", "message_json", "message_id", sessionIDs, recentFactsPerSession)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
turns, turnsErr = s.queryRecentSessionFacts(ctx, "workbench_turns", "turn_json", "turn_id", sessionIDs, recentFactsPerSession)
|
||||||
|
}()
|
||||||
|
wait.Wait()
|
||||||
|
if messageSummaryErr != nil {
|
||||||
|
return nil, nil, nil, messageSummaryErr
|
||||||
}
|
}
|
||||||
limit := sessionCount * 8
|
if messagesErr != nil {
|
||||||
if limit < sessionCount {
|
return nil, nil, nil, messagesErr
|
||||||
return sessionCount
|
|
||||||
}
|
}
|
||||||
if limit > 1000 {
|
if turnsErr != nil {
|
||||||
return 1000
|
return nil, nil, nil, turnsErr
|
||||||
}
|
}
|
||||||
return limit
|
return messageSummaries, messages, turns, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func summaryTraceIDs(sessions []map[string]any, facts map[string][]any) []string {
|
func summaryTraceIDs(sessions []map[string]any, facts map[string][]any) []string {
|
||||||
@@ -1741,6 +1756,75 @@ LEFT JOIN LATERAL (
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) queryRecentSessionFacts(ctx context.Context, table string, jsonColumn string, idColumn string, sessionIDs []string, perSessionLimit int) ([]any, error) {
|
||||||
|
cleaned := uniqueStrings(sessionIDs)
|
||||||
|
if len(cleaned) == 0 || perSessionLimit <= 0 {
|
||||||
|
return []any{}, nil
|
||||||
|
}
|
||||||
|
if !validRecentSessionFactSource(table, jsonColumn, idColumn) {
|
||||||
|
return nil, wrapQueryStage("workbench_runtime.db.session_recent_facts", fmt.Errorf("unsupported recent session fact source: %s", table))
|
||||||
|
}
|
||||||
|
args := make([]any, 0, len(cleaned)+1)
|
||||||
|
valueRows := make([]string, 0, len(cleaned))
|
||||||
|
for _, sid := range cleaned {
|
||||||
|
args = append(args, sid)
|
||||||
|
valueRows = append(valueRows, fmt.Sprintf("($%d::text)", len(args)))
|
||||||
|
}
|
||||||
|
args = append(args, perSessionLimit)
|
||||||
|
limitPlaceholder := fmt.Sprintf("$%d", len(args))
|
||||||
|
sqlText := fmt.Sprintf(`SELECT recent.%s
|
||||||
|
FROM (VALUES %s) AS requested(session_id)
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT %s, updated_at, %s
|
||||||
|
FROM %s
|
||||||
|
WHERE session_id = requested.session_id
|
||||||
|
ORDER BY updated_at DESC, %s DESC
|
||||||
|
LIMIT %s
|
||||||
|
) AS recent
|
||||||
|
ORDER BY recent.updated_at DESC, recent.%s DESC`, jsonColumn, strings.Join(valueRows, ", "), jsonColumn, idColumn, table, idColumn, limitPlaceholder, idColumn)
|
||||||
|
stage := "workbench_runtime.db." + factFamilyForTable(table) + "_recent_by_session"
|
||||||
|
result := []any{}
|
||||||
|
err := s.withOtelInternalSpan(ctx, stage, s.dbQueryAttrs(table, len(args), map[string]any{
|
||||||
|
"db.index.expected": recentSessionFactExpectedIndex(table),
|
||||||
|
"db.query.shape": "values_lateral_per_session_limit",
|
||||||
|
"db.query.session_count": int64(len(cleaned)),
|
||||||
|
"db.query.per_session_max": int64(perSessionLimit),
|
||||||
|
}), func(spanCtx context.Context) error {
|
||||||
|
rows, err := s.db.QueryContext(spanCtx, sqlText, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var raw sql.NullString
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var item any
|
||||||
|
if raw.Valid && strings.TrimSpace(raw.String) != "" && json.Unmarshal([]byte(raw.String), &item) == nil && item != nil {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapQueryStage(stage, err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRecentSessionFactSource(table string, jsonColumn string, idColumn string) bool {
|
||||||
|
return table == "workbench_messages" && jsonColumn == "message_json" && idColumn == "message_id" ||
|
||||||
|
table == "workbench_turns" && jsonColumn == "turn_json" && idColumn == "turn_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentSessionFactExpectedIndex(table string) string {
|
||||||
|
if table == "workbench_messages" {
|
||||||
|
return "idx_workbench_messages_session_updated"
|
||||||
|
}
|
||||||
|
return "idx_workbench_turns_session_updated"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) queryAgentTraceEvents(ctx context.Context, query traceEventsQuery) ([]any, error) {
|
func (s *Server) queryAgentTraceEvents(ctx context.Context, query traceEventsQuery) ([]any, error) {
|
||||||
clauses := []string{}
|
clauses := []string{}
|
||||||
args := []any{}
|
args := []any{}
|
||||||
|
|||||||
@@ -98,32 +98,30 @@ function formatSessionUpdatedTime(value: string | null | undefined): string {
|
|||||||
<h2>显式会话</h2>
|
<h2>显式会话</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="session-rail-header-actions">
|
<div class="session-rail-header-actions">
|
||||||
<button v-if="!collapsed" id="session-detail-toggle" class="message-detail-button session-detail-button" type="button" aria-label="Session 详情" title="Session 详情" :disabled="showSessionListLoading || !activeTab" @click="detailOpen = true">!</button>
|
<button v-if="!collapsed" id="session-detail-toggle" class="message-detail-button session-detail-button" type="button" aria-label="Session 详情" title="Session 详情" :disabled="!activeTab" @click="detailOpen = true">!</button>
|
||||||
<button v-if="!collapsed" id="session-create" class="btn btn-primary btn-sm" type="button" @click="workbench.createSession">新建</button>
|
<button v-if="!collapsed" id="session-create" class="btn btn-primary btn-sm" type="button" @click="workbench.createSession">新建</button>
|
||||||
<button id="session-collapse-toggle" class="btn btn-secondary btn-sm session-collapse-toggle" type="button" :aria-label="collapsed ? '展开 Sessions' : '折叠 Sessions'" :title="collapsed ? '展开 Sessions' : '折叠 Sessions'" :aria-expanded="!collapsed" @click="toggleCollapsed">{{ collapsed ? '›' : '‹' }}</button>
|
<button id="session-collapse-toggle" class="btn btn-secondary btn-sm session-collapse-toggle" type="button" :aria-label="collapsed ? '展开 Sessions' : '折叠 Sessions'" :title="collapsed ? '展开 Sessions' : '折叠 Sessions'" :aria-expanded="!collapsed" @click="toggleCollapsed">{{ collapsed ? '›' : '‹' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!collapsed" class="session-list" id="session-tabs" :data-loading="showSessionListLoading">
|
<div v-if="!collapsed" class="session-list" id="session-tabs" :data-loading="showSessionListLoading">
|
||||||
<LoadingState v-if="showSessionListLoading" class="session-list-loading" label="加载中" />
|
<LoadingState v-if="showSessionListLoading" class="session-list-refreshing" label="正在加载其他会话" compact />
|
||||||
<template v-else>
|
<button v-for="tab in workbench.sessionTabs" :key="tab.sessionId || tab.key" class="session-tab" :data-active="tab.active" :data-running="tab.running" :data-status="tab.status || undefined" :data-projection-health="tab.projectionHealth || undefined" :data-session-id="tab.sessionId || undefined" type="button" :title="tab.tooltip" @click="workbench.selectSession(tab)">
|
||||||
<button v-for="tab in workbench.sessionTabs" :key="tab.sessionId || tab.key" class="session-tab" :data-active="tab.active" :data-running="tab.running" :data-status="tab.status || undefined" :data-projection-health="tab.projectionHealth || undefined" :data-session-id="tab.sessionId || undefined" type="button" :title="tab.tooltip" @click="workbench.selectSession(tab)">
|
<span class="session-tab-title">{{ tab.label }}</span>
|
||||||
<span class="session-tab-title">{{ tab.label }}</span>
|
<span v-if="tab.projectionDiagnosticText" class="session-tab-diagnostic">状态更新异常</span>
|
||||||
<span v-if="tab.projectionDiagnosticText" class="session-tab-diagnostic">状态更新异常</span>
|
<time class="session-tab-time" :datetime="tab.updatedAt || tab.startedAt || undefined">{{ formatSessionUpdatedTime(tab.updatedAt || tab.startedAt) }}</time>
|
||||||
<time class="session-tab-time" :datetime="tab.updatedAt || tab.startedAt || undefined">{{ formatSessionUpdatedTime(tab.updatedAt || tab.startedAt) }}</time>
|
</button>
|
||||||
|
<p v-if="workbench.sessionTabs.length === 0 && !showSessionListLoading" class="muted-box">没有 session。发送前必须显式新建或选择 session。</p>
|
||||||
|
<div v-if="workbench.sessionTabs.length > 0 && !showSessionListLoading" class="session-list-footer">
|
||||||
|
<button v-if="workbench.sessionListHasMore" id="session-load-more" class="btn btn-secondary btn-sm session-load-more" type="button" :disabled="workbench.sessionListLoadingMore" @click="workbench.loadMoreSessions">
|
||||||
|
{{ workbench.sessionListLoadingMore ? '加载中' : '加载更多' }}
|
||||||
</button>
|
</button>
|
||||||
<p v-if="workbench.sessionTabs.length === 0" class="muted-box">没有 session。发送前必须显式新建或选择 session。</p>
|
<p v-else id="session-list-complete" class="session-list-terminal">已显示全部 sessions</p>
|
||||||
<div v-else class="session-list-footer">
|
<p v-if="workbench.sessionListLoadMoreError" id="session-load-more-error" class="session-list-error">{{ workbench.sessionListLoadMoreError }}</p>
|
||||||
<button v-if="workbench.sessionListHasMore" id="session-load-more" class="btn btn-secondary btn-sm session-load-more" type="button" :disabled="workbench.sessionListLoadingMore" @click="workbench.loadMoreSessions">
|
</div>
|
||||||
{{ workbench.sessionListLoadingMore ? '加载中' : '加载更多' }}
|
|
||||||
</button>
|
|
||||||
<p v-else id="session-list-complete" class="session-list-terminal">已显示全部 sessions</p>
|
|
||||||
<p v-if="workbench.sessionListLoadMoreError" id="session-load-more-error" class="session-list-error">{{ workbench.sessionListLoadMoreError }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!collapsed" class="session-rail-actions">
|
<div v-if="!collapsed" class="session-rail-actions">
|
||||||
<button id="session-copy" class="btn btn-secondary btn-sm" type="button" :disabled="showSessionListLoading || !activeTab" @click="copyActive">复制</button>
|
<button id="session-copy" class="btn btn-secondary btn-sm" type="button" :disabled="!activeTab" @click="copyActive">复制</button>
|
||||||
<button id="session-delete" class="btn btn-secondary btn-sm" type="button" :disabled="showSessionListLoading || !activeTab" @click="workbench.deleteCurrentSession">删除当前</button>
|
<button id="session-delete" class="btn btn-secondary btn-sm" type="button" :disabled="!activeTab" @click="workbench.deleteCurrentSession">删除当前</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="detailOpen && activeTab" class="workbench-dialog-backdrop" role="presentation" @click.self="detailOpen = false">
|
<div v-if="detailOpen && activeTab" class="workbench-dialog-backdrop" role="presentation" @click.self="detailOpen = false">
|
||||||
<section id="session-detail-dialog" class="workbench-dialog" role="dialog" aria-modal="true" aria-labelledby="session-detail-title">
|
<section id="session-detail-dialog" class="workbench-dialog" role="dialog" aria-modal="true" aria-labelledby="session-detail-title">
|
||||||
|
|||||||
@@ -268,13 +268,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.session-list[data-loading="true"] {
|
.session-list[data-loading="true"] {
|
||||||
align-content: center;
|
overflow: auto;
|
||||||
justify-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-list-loading {
|
.session-list-refreshing {
|
||||||
min-height: 120px;
|
width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
padding: 6px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-panel {
|
.conversation-panel {
|
||||||
|
|||||||
Reference in New Issue
Block a user