fix(chat): 修复会话刷新丢失最终答案(#3) + 反向分页/详情懒加载/会话URL持久化
- 后端消息列表默认按 id 升序取最旧一页,长会话刷新后最终答案落在页外而丢失
(发新消息推进游标才重现)。改为:
- db.ConvActivityPage:反向分页,取最新/更早一页 + hasMore
- 消息接口按 since(增量)/before(更早页)/最新页 三种模式返回
- 前端聊天页:
- 打开会话仅加载最新一页,滚动到顶部再反向加载更早(保持滚动位置不跳)
- Transcript 的 UserRow/AnswerBlock 改为进入视口才拉 detail,避免打开会话即对
整页每步发详情请求
- 选中会话 id 持久化到 URL(?c=<id>),刷新回到当前会话
- 修复新建会话后被"清理失效 id"effect 误清导致停留在新建页、输入框内容与灰色
发送按钮卡死:移除该 effect + onStarted 乐观插入
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
349b0d104d
commit
5000aadd8f
5 files changed
+207
-17
No files matched your search
@@ -174,6 +174,49 @@ FROM conversation_activities WHERE conversation_id=$1 AND id>$2 ORDER BY id LIMI
|
|||||||
return out, cursor, rows.Err()
|
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.
|
// ConvActivityDetail lazily returns the full detail blob for one step.
|
||||||
func (d *DB) ConvActivityDetail(convID, id int64) (string, error) {
|
func (d *DB) ConvActivityDetail(convID, id int64) (string, error) {
|
||||||
var s sql.NullString
|
var s sql.NullString
|
||||||
|
|||||||
+29
-6
@@ -171,16 +171,39 @@ func (s *Server) pgConversationMessages(w http.ResponseWriter, r *http.Request)
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
|
q := r.URL.Query()
|
||||||
items, cursor, err := pg.ConvActivityList(c.ID, since, 0)
|
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 {
|
if err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.chatMu.Lock()
|
cursor := before
|
||||||
running := s.chatBusy[s.convBusyKey(c.ID)]
|
if n := len(items); n > 0 {
|
||||||
s.chatMu.Unlock()
|
cursor = items[n-1].ID
|
||||||
writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running})
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running, "hasMore": hasMore})
|
||||||
}
|
}
|
||||||
|
|
||||||
// pgConversationMsgDetail lazily returns one step's full detail blob.
|
// pgConversationMsgDetail lazily returns one step's full detail blob.
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ function fmtDuration(ms: number): string {
|
|||||||
return `${h}h${String(m % 60).padStart(2, "0")}m`;
|
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
|
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
|
||||||
// console — shown while a turn is streaming.
|
// console — shown while a turn is streaming.
|
||||||
function LiveBadge() {
|
function LiveBadge() {
|
||||||
@@ -307,7 +312,11 @@ function ChatView({
|
|||||||
const [input, setInput] = React.useState("");
|
const [input, setInput] = React.useState("");
|
||||||
const [sending, setSending] = React.useState(false);
|
const [sending, setSending] = React.useState(false);
|
||||||
const [stopping, setStopping] = 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 agent = agents.find((a) => a.key === conv.agent_key);
|
||||||
const currentProfileId = conv.llm_profile_id ?? null;
|
const currentProfileId = conv.llm_profile_id ?? null;
|
||||||
|
|
||||||
@@ -335,18 +344,28 @@ function ChatView({
|
|||||||
return null;
|
return null;
|
||||||
}, [messages]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
cursorRef.current = 0;
|
cursorRef.current = 0;
|
||||||
|
earliestRef.current = 0;
|
||||||
|
hasMoreRef.current = false;
|
||||||
|
setHasMore(false);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
let live = true;
|
let live = true;
|
||||||
api
|
api
|
||||||
.conversationMessages(conv.id, 0)
|
.conversationHistory(conv.id, 0, HISTORY_PAGE)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
setMessages(r.items);
|
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);
|
setRunning(r.running);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -389,15 +408,46 @@ function ChatView({
|
|||||||
null,
|
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(() => {
|
React.useEffect(() => {
|
||||||
const vp = viewport();
|
const vp = viewport();
|
||||||
if (!vp) return;
|
if (!vp) return;
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
|
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 });
|
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => vp.removeEventListener("scroll", onScroll);
|
return () => vp.removeEventListener("scroll", onScroll);
|
||||||
}, [viewport]);
|
}, [viewport, loadEarlier]);
|
||||||
// open/switch a conversation → jump to the latest (bottom)
|
// open/switch a conversation → jump to the latest (bottom)
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const vp = viewport();
|
const vp = viewport();
|
||||||
@@ -512,7 +562,14 @@ function ChatView({
|
|||||||
开始和「{agent?.name ?? conv.agent_key}」对话
|
开始和「{agent?.name ?? conv.agent_key}」对话
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
|
<>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]">
|
||||||
|
向上滚动加载更早的消息…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -642,6 +699,25 @@ export default function ChatPage() {
|
|||||||
reloadConvs();
|
reloadConvs();
|
||||||
}, [reloadConvs]);
|
}, [reloadConvs]);
|
||||||
|
|
||||||
|
// Restore the open conversation from the URL (?c=<id>) 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;
|
const selected = convs.find((c) => c.id === selectedId) ?? null;
|
||||||
// conversation agents: custom agents + conversational built-ins (role=assistant,
|
// conversation agents: custom agents + conversational built-ins (role=assistant,
|
||||||
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
|
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
|
||||||
@@ -728,8 +804,12 @@ export default function ChatPage() {
|
|||||||
agents={chatAgents}
|
agents={chatAgents}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
onStarted={(c) => {
|
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);
|
setSelectedId(c.id);
|
||||||
|
reloadConvs();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,6 +39,36 @@ function workerColor(name: string): string {
|
|||||||
const chip = (worker: string) =>
|
const chip = (worker: string) =>
|
||||||
"mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker);
|
"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<HTMLDivElement | null>, boolean] {
|
||||||
|
const ref = React.useRef<HTMLDivElement | null>(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 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
|
// a run of consecutive conversational steps (text/thinking/result) from the same
|
||||||
// agent is one "message".
|
// 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).
|
// 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<string> }) {
|
function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise<string> }) {
|
||||||
const Icon = intent ? CrosshairIcon : UserIcon;
|
const Icon = intent ? CrosshairIcon : UserIcon;
|
||||||
|
const [ref, inView] = useInView();
|
||||||
const [full, setFull] = React.useState<string | null>(null);
|
const [full, setFull] = React.useState<string | null>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!inView) return; // fetch the full message only when the bubble nears view
|
||||||
let live = true;
|
let live = true;
|
||||||
getDetail(step.seq)
|
getDetail(step.seq)
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
@@ -430,9 +462,9 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean
|
|||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [step.seq, getDetail, step.summary]);
|
}, [inView, step.seq, getDetail, step.summary]);
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 mb-2 flex justify-end gap-2">
|
<div ref={ref} className="mt-3 mb-2 flex justify-end gap-2">
|
||||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
|
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
|
||||||
{full ?? step.summary}
|
{full ?? step.summary}
|
||||||
</div>
|
</div>
|
||||||
@@ -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
|
// collapsed. The summary is a truncated first line, so the full text is pulled
|
||||||
// from the detail and shown inline.
|
// from the detail and shown inline.
|
||||||
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
|
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
|
||||||
|
const [ref, inView] = useInView();
|
||||||
const [full, setFull] = React.useState<string | null>(null);
|
const [full, setFull] = React.useState<string | null>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!inView) return; // fetch the full answer only when it nears view
|
||||||
let live = true;
|
let live = true;
|
||||||
getDetail(step.seq)
|
getDetail(step.seq)
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
@@ -460,9 +494,9 @@ function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: num
|
|||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [step.seq, getDetail, step.summary]);
|
}, [inView, step.seq, getDetail, step.summary]);
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 mt-1 flex">
|
<div ref={ref} className="mb-2 mt-1 flex">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
|
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
|
||||||
|
|||||||
@@ -275,10 +275,20 @@ export const api = {
|
|||||||
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
|
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
|
||||||
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
|
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
|
||||||
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${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) =>
|
conversationMessages: (id: number, since = 0) =>
|
||||||
get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then(
|
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 }),
|
(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) =>
|
conversationMsgDetail: (id: number, seq: number) =>
|
||||||
get<{ detail: string }>(`/conversations/${id}/messages/${seq}`).then((r) => r.detail ?? ""),
|
get<{ detail: string }>(`/conversations/${id}/messages/${seq}`).then((r) => r.detail ?? ""),
|
||||||
sendConversationMessage: (id: number, message: string) =>
|
sendConversationMessage: (id: number, message: string) =>
|
||||||
|
|||||||
Reference in new issue
Block a user