From 5000aadd8fe46e4bded1cb9d37991e6814fa1d6b Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:45:33 -0400 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=B7=E6=96=B0=E4=B8=A2=E5=A4=B1=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E7=AD=94=E6=A1=88(#3)=20+=20=E5=8F=8D=E5=90=91=E5=88=86?= =?UTF-8?q?=E9=A1=B5/=E8=AF=A6=E6=83=85=E6=87=92=E5=8A=A0=E8=BD=BD/?= =?UTF-8?q?=E4=BC=9A=E8=AF=9DURL=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端消息列表默认按 id 升序取最旧一页,长会话刷新后最终答案落在页外而丢失 (发新消息推进游标才重现)。改为: - db.ConvActivityPage:反向分页,取最新/更早一页 + hasMore - 消息接口按 since(增量)/before(更早页)/最新页 三种模式返回 - 前端聊天页: - 打开会话仅加载最新一页,滚动到顶部再反向加载更早(保持滚动位置不跳) - Transcript 的 UserRow/AnswerBlock 改为进入视口才拉 detail,避免打开会话即对 整页每步发详情请求 - 选中会话 id 持久化到 URL(?c=),刷新回到当前会话 - 修复新建会话后被"清理失效 id"effect 误清导致停留在新建页、输入框内容与灰色 发送按钮卡死:移除该 effect + onStarted 乐观插入 Co-Authored-By: Claude Opus 5 (1M context) --- db/conversation.go | 43 ++++++++++++++ server/conversations.go | 35 ++++++++++-- web/src/app/(main)/chat/page.tsx | 94 ++++++++++++++++++++++++++++--- web/src/components/transcript.tsx | 42 ++++++++++++-- web/src/lib/api.ts | 10 ++++ 5 files changed, 207 insertions(+), 17 deletions(-) diff --git a/db/conversation.go b/db/conversation.go index 18f1a52..9e7e97a 100644 --- a/db/conversation.go +++ b/db/conversation.go @@ -174,6 +174,49 @@ FROM conversation_activities WHERE conversation_id=$1 AND id>$2 ORDER BY id LIMI return out, cursor, rows.Err() } +// ConvActivityPage returns one page for reverse (newest-first) pagination: up to +// `limit` steps ending before id `before` (exclusive; before<=0 = the latest +// page), returned in ASCENDING id order. hasMore reports whether still-older steps +// exist before the returned window, so the client can stop loading earlier history +// on scroll-up. Summary-only columns (detail is lazy-loaded via ConvActivityDetail). +func (d *DB) ConvActivityPage(convID, before int64, limit int) ([]Activity, bool, error) { + if limit <= 0 { + limit = 200 + } + const cols = `id, COALESCE(worker,''), COALESCE(kind,''), COALESCE(tool,''), COALESCE(tool_use_id,''), is_error, COALESCE(summary,''), created_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens` + // fetch one extra row to detect whether older history remains before this window. + rows, err := d.Query(`SELECT `+cols+` +FROM conversation_activities +WHERE conversation_id=$1 AND ($2 <= 0 OR id < $2) +ORDER BY id DESC LIMIT $3`, convID, before, limit+1) + if err != nil { + return nil, false, err + } + defer rows.Close() + desc := []Activity{} + for rows.Next() { + var a Activity + if err := rows.Scan(&a.ID, &a.Worker, &a.Kind, &a.Tool, &a.ToolUseID, &a.IsError, &a.Summary, &a.CreatedAt, + &a.InputTokens, &a.OutputTokens, &a.CacheReadTokens, &a.CacheWriteTokens); err != nil { + return nil, false, err + } + desc = append(desc, a) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + hasMore := len(desc) > limit + if hasMore { + desc = desc[:limit] + } + // reverse the newest-first window into ascending id order for display. + out := make([]Activity, len(desc)) + for i, a := range desc { + out[len(desc)-1-i] = a + } + return out, hasMore, nil +} + // ConvActivityDetail lazily returns the full detail blob for one step. func (d *DB) ConvActivityDetail(convID, id int64) (string, error) { var s sql.NullString diff --git a/server/conversations.go b/server/conversations.go index 5b9f480..dc2dba4 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -171,16 +171,39 @@ func (s *Server) pgConversationMessages(w http.ResponseWriter, r *http.Request) if !ok { return } - since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) - items, cursor, err := pg.ConvActivityList(c.ID, since, 0) + q := r.URL.Query() + limit := atoiDefault(q.Get("limit"), 200) + s.chatMu.Lock() + running := s.chatBusy[s.convBusyKey(c.ID)] + s.chatMu.Unlock() + + // Incremental tail: ?since=N returns steps after id N (ASC) — used by the live + // poll and the post-send fetch. A generous cap so a burst is never dropped. + if sv := q.Get("since"); sv != "" { + since, _ := strconv.ParseInt(sv, 10, 64) + items, cursor, err := pg.ConvActivityList(c.ID, since, max(limit, 1000)) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running, "hasMore": false}) + return + } + + // History page (reverse pagination): no ?since → the latest page; ?before=N → + // the page of older steps ending before id N. hasMore lets the client stop + // loading earlier history once the top of the thread is reached. + before, _ := strconv.ParseInt(q.Get("before"), 10, 64) + items, hasMore, err := pg.ConvActivityPage(c.ID, before, limit) if err != nil { writeErr(w, 500, err.Error()) return } - s.chatMu.Lock() - running := s.chatBusy[s.convBusyKey(c.ID)] - s.chatMu.Unlock() - writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running}) + cursor := before + if n := len(items); n > 0 { + cursor = items[n-1].ID + } + writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running, "hasMore": hasMore}) } // pgConversationMsgDetail lazily returns one step's full detail blob. diff --git a/web/src/app/(main)/chat/page.tsx b/web/src/app/(main)/chat/page.tsx index 179e021..7f2826a 100644 --- a/web/src/app/(main)/chat/page.tsx +++ b/web/src/app/(main)/chat/page.tsx @@ -54,6 +54,11 @@ function fmtDuration(ms: number): string { return `${h}h${String(m % 60).padStart(2, "0")}m`; } +// HISTORY_PAGE is how many steps one history page loads: the latest page on open, +// then one more page each time the user scrolls to the top. Kept modest so a long +// thread stays snappy (only ~a page of rows is in the DOM until you scroll up). +const HISTORY_PAGE = 200; + // LiveBadge is the small pulsing "实时" chip reused from the task's main-agent // console — shown while a turn is streaming. function LiveBadge() { @@ -307,7 +312,11 @@ function ChatView({ const [input, setInput] = React.useState(""); const [sending, setSending] = React.useState(false); const [stopping, setStopping] = React.useState(false); - const cursorRef = React.useRef(0); + const cursorRef = React.useRef(0); // newest loaded id — incremental-tail anchor + const earliestRef = React.useRef(0); // earliest loaded id — reverse-pagination anchor + const hasMoreRef = React.useRef(false); // older history remains above the loaded window + const loadingMoreRef = React.useRef(false); // guard: one scroll-up load at a time + const [hasMore, setHasMore] = React.useState(false); // drives the "load earlier" hint const agent = agents.find((a) => a.key === conv.agent_key); const currentProfileId = conv.llm_profile_id ?? null; @@ -335,18 +344,28 @@ function ChatView({ return null; }, [messages]); - // reset + load whenever the selected conversation changes. + // reset + load whenever the selected conversation changes. Load only the LATEST + // page on open — a long thread's final answer sits at the very end, so the newest + // page shows it immediately (issue #3: opening/refresh used to load the oldest + // page, so the completed result was missing until another message advanced the + // cursor). Older history streams in on scroll-up (loadEarlier below). React.useEffect(() => { cursorRef.current = 0; + earliestRef.current = 0; + hasMoreRef.current = false; + setHasMore(false); setMessages([]); setRunning(false); let live = true; api - .conversationMessages(conv.id, 0) + .conversationHistory(conv.id, 0, HISTORY_PAGE) .then((r) => { if (!live) return; setMessages(r.items); - cursorRef.current = r.cursor; + cursorRef.current = r.cursor; // newest id → incremental-tail anchor + earliestRef.current = r.items.length ? r.items[0].seq : 0; + hasMoreRef.current = r.hasMore; + setHasMore(r.hasMore); setRunning(r.running); }) .catch(() => {}); @@ -389,15 +408,46 @@ function ChatView({ null, [], ); + // Scroll-up loads one older page and prepends it, preserving the visual position + // so the view doesn't jump (record height/offset before, restore the delta after). + const loadEarlier = React.useCallback(async () => { + if (loadingMoreRef.current || !hasMoreRef.current) return; + const vp = viewport(); + if (!vp) return; + loadingMoreRef.current = true; + const prevH = vp.scrollHeight; + const prevTop = vp.scrollTop; + try { + const r = await api.conversationHistory(conv.id, earliestRef.current, HISTORY_PAGE); + if (r.items.length) { + setMessages((prev) => { + const seen = new Set(prev.map((a) => a.seq)); + return [...r.items.filter((a) => !seen.has(a.seq)), ...prev]; + }); + earliestRef.current = r.items[0].seq; + } + hasMoreRef.current = r.hasMore; + setHasMore(r.hasMore); + requestAnimationFrame(() => { + const v = viewport(); + if (v) v.scrollTop = prevTop + (v.scrollHeight - prevH); + }); + } catch { + /* transient — a later scroll retries */ + } finally { + loadingMoreRef.current = false; + } + }, [conv.id, viewport]); React.useEffect(() => { const vp = viewport(); if (!vp) return; const onScroll = () => { atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60; + if (vp.scrollTop <= 80) void loadEarlier(); // near top → pull an older page }; vp.addEventListener("scroll", onScroll, { passive: true }); return () => vp.removeEventListener("scroll", onScroll); - }, [viewport]); + }, [viewport, loadEarlier]); // open/switch a conversation → jump to the latest (bottom) React.useLayoutEffect(() => { const vp = viewport(); @@ -512,7 +562,14 @@ function ChatView({ 开始和「{agent?.name ?? conv.agent_key}」对话 ) : ( - + <> + {hasMore && ( +
+ 向上滚动加载更早的消息… +
+ )} + + )} @@ -642,6 +699,25 @@ export default function ChatPage() { reloadConvs(); }, [reloadConvs]); + // Restore the open conversation from the URL (?c=) on mount, so a refresh + // returns to the same thread instead of the empty draft view. Runs after + // hydration (not a lazy useState init) to avoid a server/client mismatch. + React.useEffect(() => { + const c = new URLSearchParams(window.location.search).get("c"); + const id = c ? Number(c) : NaN; + if (Number.isFinite(id)) setSelectedId(id); + }, []); + // Mirror the current selection into the URL (replaceState → no history spam). A + // selectedId with no matching conversation (e.g. a stale ?c=, or a just-created + // one before reloadConvs lands) simply renders the draft view — harmless — so we + // deliberately do NOT auto-clear it here (that raced new-conversation creation). + React.useEffect(() => { + const url = new URL(window.location.href); + if (selectedId != null) url.searchParams.set("c", String(selectedId)); + else url.searchParams.delete("c"); + window.history.replaceState(null, "", url); + }, [selectedId]); + const selected = convs.find((c) => c.id === selectedId) ?? null; // conversation agents: custom agents + conversational built-ins (role=assistant, // e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker) @@ -728,8 +804,12 @@ export default function ChatPage() { agents={chatAgents} profiles={profiles} onStarted={(c) => { - reloadConvs(); + // Insert the new conversation immediately so `selected` resolves to + // it on this render (switching to ChatView right away, before the + // async reloadConvs lands); reloadConvs then reconciles titles etc. + setConvs((prev) => (prev.some((x) => x.id === c.id) ? prev : [c, ...prev])); setSelectedId(c.id); + reloadConvs(); }} /> )} diff --git a/web/src/components/transcript.tsx b/web/src/components/transcript.tsx index 86e14ad..ec827d2 100644 --- a/web/src/components/transcript.tsx +++ b/web/src/components/transcript.tsx @@ -39,6 +39,36 @@ function workerColor(name: string): string { const chip = (worker: string) => "mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker); +// useInView latches true when the ref'd element first comes within `rootMargin` of +// the enclosing scroll viewport. Blocks that always show their full body (user +// bubbles, answers) use it to defer fetching that body until they're about to be +// seen — so opening a long thread doesn't fire a detail request for every off- +// screen step. Observes the ScrollArea viewport (falls back to eager load when +// IntersectionObserver is unavailable, e.g. SSR). +function useInView(rootMargin = "400px"): [React.RefObject, boolean] { + const ref = React.useRef(null); + const [inView, setInView] = React.useState(false); + React.useEffect(() => { + if (inView) return; // latch: once seen, stop observing + const el = ref.current; + if (!el) return; + if (typeof IntersectionObserver === "undefined") { + setInView(true); + return; + } + const root = el.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null; + const ob = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) setInView(true); + }, + { root, rootMargin }, + ); + ob.observe(el); + return () => ob.disconnect(); + }, [inView, rootMargin]); + return [ref, inView]; +} + // A tool group pairs a tool_use with its matching tool_result (by tool_use_id); // a run of consecutive conversational steps (text/thinking/result) from the same // agent is one "message". @@ -417,8 +447,10 @@ function MessageBlock({ // and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps). function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise }) { const Icon = intent ? CrosshairIcon : UserIcon; + const [ref, inView] = useInView(); const [full, setFull] = React.useState(null); React.useEffect(() => { + if (!inView) return; // fetch the full message only when the bubble nears view let live = true; getDetail(step.seq) .then((d) => { @@ -430,9 +462,9 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean return () => { live = false; }; - }, [step.seq, getDetail, step.summary]); + }, [inView, step.seq, getDetail, step.summary]); return ( -
+
{full ?? step.summary}
@@ -447,8 +479,10 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean // collapsed. The summary is a truncated first line, so the full text is pulled // from the detail and shown inline. function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise }) { + const [ref, inView] = useInView(); const [full, setFull] = React.useState(null); React.useEffect(() => { + if (!inView) return; // fetch the full answer only when it nears view let live = true; getDetail(step.seq) .then((d) => { @@ -460,9 +494,9 @@ function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: num return () => { live = false; }; - }, [step.seq, getDetail, step.summary]); + }, [inView, step.seq, getDetail, step.summary]); return ( -
+
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }), deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`), + // Incremental tail: steps after `since` (id ASC) — live poll + post-send fetch. conversationMessages: (id: number, since = 0) => get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then( (r) => ({ items: arr(r.items), cursor: r.cursor ?? 0, running: !!r.running }), ), + // Reverse pagination: the latest `limit` steps (before=0) or the page of older + // steps ending before id `before`. hasMore = still-older steps exist. + conversationHistory: (id: number, before = 0, limit = 200) => { + const q = new URLSearchParams({ limit: String(limit) }); + if (before > 0) q.set("before", String(before)); + return get<{ items: Activity[]; cursor: number; running: boolean; hasMore: boolean }>( + `/conversations/${id}/messages?${q.toString()}`, + ).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0, running: !!r.running, hasMore: !!r.hasMore })); + }, conversationMsgDetail: (id: number, seq: number) => get<{ detail: string }>(`/conversations/${id}/messages/${seq}`).then((r) => r.detail ?? ""), sendConversationMessage: (id: number, message: string) =>