diff --git a/interfaces/admin/chat_api.py b/interfaces/admin/chat_api.py index 7e7b3e83..05aa953f 100644 --- a/interfaces/admin/chat_api.py +++ b/interfaces/admin/chat_api.py @@ -66,7 +66,8 @@ def _wiki_root_from_config() -> Path | None: root = (Path(__file__).resolve().parents[2] / root).resolve() return root -_CHAT_MSG_LIMIT = 5000 +# Default window for chat UI / admin: recent messages only (scroll-up loads older via before_id). +_CHAT_MSG_LIMIT = 80 _SESSION_TITLE_MAX_LEN = 120 _AVATAR_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 _AVATAR_MIMES = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}) @@ -1056,6 +1057,7 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor def api_chat_messages( session_id: str, limit: int = Query(default=_CHAT_MSG_LIMIT, ge=1, le=20000), + before_id: int | None = Query(default=None, ge=1), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() @@ -1066,11 +1068,24 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor if not sess: raise HTTPException(status_code=404, detail="session_not_found") meta = store.get_session_messages_meta(session_id) - msgs = store.get_messages(session_id=session_id, limit=int(limit)) + lim = int(limit) + if before_id is not None: + msgs = store.get_messages_before_id( + session_id=session_id, before_id=int(before_id), limit=lim + ) + else: + msgs = store.get_messages(session_id=session_id, limit=lim) msgs = _filter_internal_instruction_user_messages(msgs) + oldest_id = int(getattr(msgs[0], "id", 0) or 0) if msgs else 0 + has_more = bool(oldest_id) and store.exists_message_before_id( + session_id=session_id, before_id=oldest_id + ) return { "ok": True, - "message_count": len(msgs), + "message_count": int(meta.message_count or 0), + "returned_count": len(msgs), + "has_more": has_more, + "before_id": int(before_id) if before_id is not None else None, "messages": [_serialize_message(m) for m in msgs], } diff --git a/interfaces/admin/static/chat.html b/interfaces/admin/static/chat.html index 09d62fe8..52cb4628 100644 --- a/interfaces/admin/static/chat.html +++ b/interfaces/admin/static/chat.html @@ -781,7 +781,7 @@ diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index fef673c5..bcd35921 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -1,7 +1,8 @@ /* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */ const PAGE_SIZE = 35; -const CHAT_MESSAGES_FETCH_LIMIT = 5000; +/** Initial / page size for session history (older messages load on scroll-up). */ +const CHAT_MESSAGES_FETCH_LIMIT = 80; const I18N = { zh: { @@ -73,7 +74,9 @@ const I18N = { "chat.marker.summary": "文件标记:指针 {p},封套 {e}(封套内 {ep})", "chat.marker.ttl": "TTL:turn {t} / session {s} / keep {k}", "chat.marker.reclaimed": "本轮回收 turn 标记 {n}", - "chat.historyTruncated": "仅显示最近 {shown} 条,共 {total} 条", + "chat.historyTruncated": "已显示最近 {shown}/{total} 条 · 上滑加载更早", + "chat.loadingOlder": "正在加载更早消息…", + "chat.historyComplete": "已到最早消息", "reasoning.summary": "模型推理片段", "reasoning.processNotes": "主通道过程说明", "tool.streamTitle": "工具与进度(本轮)", @@ -263,7 +266,9 @@ const I18N = { "chat.marker.summary": "File markers: pointers {p}, envelope {e} (in-envelope {ep})", "chat.marker.ttl": "TTL: turn {t} / session {s} / keep {k}", "chat.marker.reclaimed": "Turn markers reclaimed {n}", - "chat.historyTruncated": "Showing last {shown} of {total} messages", + "chat.historyTruncated": "Showing latest {shown}/{total} · scroll up for older", + "chat.loadingOlder": "Loading older messages…", + "chat.historyComplete": "Reached the start of this chat", "reasoning.summary": "Model reasoning", "reasoning.processNotes": "Main-channel notes", "tool.streamTitle": "Tools & progress (this turn)", @@ -3085,7 +3090,13 @@ async function appendMessageRow(messagesEl, m, options = {}) { else innerBubble.appendChild(bar); } } - messagesEl.appendChild(bubble); + if (options.prependBefore && options.prependBefore.parentNode === messagesEl) { + messagesEl.insertBefore(bubble, options.prependBefore); + } else if (options.prepend) { + messagesEl.insertBefore(bubble, messagesEl.firstChild); + } else { + messagesEl.appendChild(bubble); + } hydrateMermaidIn(bubble); } @@ -3414,6 +3425,9 @@ async function renderChatUi() { }; messagesEl.addEventListener("scroll", () => { shouldFollowMessages = isNearBottom(); + if (messagesEl.scrollTop <= 80) { + loadOlderMessagesForActive().catch(() => {}); + } }); messagesEl.addEventListener( "load", @@ -4306,13 +4320,49 @@ async function renderChatUi() { }, }; + let historyHasMore = false; + let historyOldestId = 0; + let historyLoadedCount = 0; + let historyTotal = 0; + let historyLoadingOlder = false; + let historyCapEl = null; + + const ensureHistoryCap = () => { + if (!historyHasMore && historyLoadedCount > 0 && historyTotal > 0 && historyLoadedCount >= historyTotal) { + if (historyCapEl && historyCapEl.parentNode) historyCapEl.remove(); + historyCapEl = null; + return; + } + if (!historyHasMore && !historyLoadingOlder) { + if (historyCapEl && historyCapEl.parentNode) historyCapEl.remove(); + historyCapEl = null; + return; + } + if (!historyCapEl) historyCapEl = el("div", { class: "muted chat-msg-cap" }); + historyCapEl.textContent = historyLoadingOlder + ? t("chat.loadingOlder") + : t("chat.historyTruncated", { + shown: String(historyLoadedCount || 0), + total: String(historyTotal || historyLoadedCount || 0), + }); + if (historyCapEl.parentNode !== messagesEl) { + messagesEl.insertBefore(historyCapEl, messagesEl.firstChild); + } + }; + const loadMessagesForActive = async (opts = {}) => { loadMessagesForActive._rid = (loadMessagesForActive._rid || 0) + 1; const rid = loadMessagesForActive._rid; shouldFollowMessages = true; + historyHasMore = false; + historyOldestId = 0; + historyLoadedCount = 0; + historyTotal = 0; + historyLoadingOlder = false; if (!activeId) { loadMessagesForActive._needsWsTextFallback = true; messagesEl.innerHTML = ""; + historyCapEl = null; statusBar.textContent = sessions.length ? "" : t("chat.noSessions"); if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") })); return 0; @@ -4327,7 +4377,10 @@ async function renderChatUi() { const msgs = Array.isArray(resp.messages) ? resp.messages : []; const renderRows = _buildRenderRows(msgs); loadMessagesForActive._needsWsTextFallback = _needsWsTextFallbackFromRenderRows(renderRows); - const total = intOr(resp.message_count, msgs.length); + historyTotal = intOr(resp.message_count, msgs.length); + historyLoadedCount = msgs.length; + historyHasMore = !!resp.has_more; + historyOldestId = msgs.length ? intOr(msgs[0].id, 0) : 0; // End-of-turn hydrate: if the server returns nothing renderable yet (PG commit lag, transient API // glitch) but the caller still has a live stream worth keeping, do not wipe messagesEl — that // caused "stream flashes then entire dialog is empty" when reasoning/tool-output mode reloads. @@ -4336,14 +4389,8 @@ async function renderChatUi() { return 0; } messagesEl.innerHTML = ""; - if (total > msgs.length) { - messagesEl.appendChild( - el("div", { - class: "muted chat-msg-cap", - text: t("chat.historyTruncated", { shown: msgs.length, total }), - }), - ); - } + historyCapEl = null; + ensureHistoryCap(); if (!renderRows.length) { messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") })); } else { @@ -4363,6 +4410,49 @@ async function renderChatUi() { } }; + const loadOlderMessagesForActive = async () => { + if (!activeId || !historyHasMore || historyLoadingOlder || !historyOldestId) return 0; + if (isChatStreaming()) return 0; + historyLoadingOlder = true; + ensureHistoryCap(); + const prevHeight = messagesEl.scrollHeight; + const prevTop = messagesEl.scrollTop; + const rid = loadMessagesForActive._rid || 0; + try { + const resp = await apiGet( + `/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages?limit=${CHAT_MESSAGES_FETCH_LIMIT}&before_id=${encodeURIComponent(String(historyOldestId))}`, + ); + if (rid !== loadMessagesForActive._rid) return 0; + const msgs = Array.isArray(resp.messages) ? resp.messages : []; + if (!msgs.length) { + historyHasMore = false; + ensureHistoryCap(); + return 0; + } + const renderRows = _buildRenderRows(msgs); + historyTotal = intOr(resp.message_count, historyTotal); + historyLoadedCount += msgs.length; + historyHasMore = !!resp.has_more; + historyOldestId = intOr(msgs[0].id, historyOldestId); + ensureHistoryCap(); + const anchor = historyCapEl && historyCapEl.parentNode === messagesEl ? historyCapEl.nextSibling : messagesEl.firstChild; + for (const m of renderRows) { + await appendMessageRow(messagesEl, m, { ...rowRenderOptions, prependBefore: anchor }); + } + const delta = messagesEl.scrollHeight - prevHeight; + messagesEl.scrollTop = prevTop + delta; + return renderRows.length; + } catch (e) { + if (typeof showToast === "function") { + showToast(`${t("chat.error")}: ${String(e)}`, { kind: "error", ttlMs: 4500 }); + } + return -1; + } finally { + historyLoadingOlder = false; + ensureHistoryCap(); + } + }; + const reloadSessionsOnly = async () => { await refreshChannelPeerNameMap().catch(() => {}); const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`); diff --git a/svc/persistence/assistant_store_protocol.py b/svc/persistence/assistant_store_protocol.py index 6573a8cf..967ffc36 100644 --- a/svc/persistence/assistant_store_protocol.py +++ b/svc/persistence/assistant_store_protocol.py @@ -81,6 +81,8 @@ class AssistantStoreProtocol(Protocol): def get_llm_profile_secret(self, profile_id: 'str') -> 'Optional[str]': ... def get_messages(self, session_id: 'str', limit: 'int' = 200) -> 'list[ChatMessage]': ... def get_messages_after_id(self, *, session_id: 'str', after_id: 'int', limit: 'int' = 200) -> 'list[ChatMessage]': ... + def get_messages_before_id(self, *, session_id: 'str', before_id: 'int', limit: 'int' = 200) -> 'list[ChatMessage]': ... + def exists_message_before_id(self, *, session_id: 'str', before_id: 'int') -> 'bool': ... def get_or_create_channel_session(self, *, tenant_id: 'str', channel: 'str', external_chat_id: 'str', external_user_id: 'str', session_title: 'str') -> 'str': ... def get_or_create_channel_session_v2(self, *, tenant_id: 'str', channel: 'str', account_id: 'str', external_chat_id: 'str', external_user_id: 'str', session_title: 'str') -> 'str': ... def get_secret(self, key: 'str') -> 'Optional[str]': ... diff --git a/svc/persistence/sa_repos/chat_messages.py b/svc/persistence/sa_repos/chat_messages.py index 825bc447..ba439154 100644 --- a/svc/persistence/sa_repos/chat_messages.py +++ b/svc/persistence/sa_repos/chat_messages.py @@ -237,6 +237,102 @@ class ChatMessagesSaRepository: rows.insert(0, dict(arow)) return [_row_to_chat_message(r) for r in rows] + def get_messages_before_id( + self, *, session_id: str, before_id: int, limit: int + ) -> list[ChatMessage]: + """Return up to ``limit`` messages with id < ``before_id``, ASC within the window. + + Same orphan-tool prepend behavior as :meth:`get_messages_recent_asc` so a page + that starts mid-tool-turn still includes the parent assistant row. + """ + if limit <= 0: + return [] + sid = str(session_id or "").strip() + if not sid: + return [] + bid = int(before_id or 0) + if bid <= 0: + return [] + lim = max(1, min(int(limit), 2000)) + ids_sq = ( + select(chat_message.c.id) + .where(chat_message.c.session_id == sid, chat_message.c.id < bid) + .order_by(chat_message.c.id.desc()) + .limit(lim) + .scalar_subquery() + ) + stmt = ( + select( + chat_message.c.id, + chat_message.c.session_id, + chat_message.c.role, + chat_message.c.content, + chat_message.c.tool_calls, + chat_message.c.attachments, + chat_message.c.turn_uuid, + chat_message.c.event_type, + chat_message.c.event_payload, + chat_message.c.timestamp, + ) + .where(chat_message.c.session_id == sid, chat_message.c.id.in_(ids_sq)) + .order_by(chat_message.c.id.asc()) + ) + prepended: set[int] = set() + with self._engine.connect() as conn: + rows: list[dict[str, Any]] = [dict(r) for r in conn.execute(stmt).mappings().all()] + while rows: + first = rows[0] + if str(first.get("role") or "") != "tool": + break + aid = _tool_row_assistant_message_id(first.get("tool_calls")) + if aid is None: + break + first_id = int(first["id"]) + if aid >= first_id: + break + if any(int(r["id"]) == int(aid) for r in rows): + break + if int(aid) in prepended: + break + arow = conn.execute( + select( + chat_message.c.id, + chat_message.c.session_id, + chat_message.c.role, + chat_message.c.content, + chat_message.c.tool_calls, + chat_message.c.attachments, + chat_message.c.turn_uuid, + chat_message.c.event_type, + chat_message.c.event_payload, + chat_message.c.timestamp, + ) + .where(chat_message.c.session_id == sid, chat_message.c.id == int(aid)) + .limit(1) + ).mappings().first() + if not arow: + break + prepended.add(int(aid)) + rows.insert(0, dict(arow)) + return [_row_to_chat_message(r) for r in rows] + + def exists_message_before_id(self, *, session_id: str, before_id: int) -> bool: + sid = str(session_id or "").strip() + bid = int(before_id or 0) + if not sid or bid <= 0: + return False + with self._engine.connect() as conn: + return bool( + conn.execute( + select( + exists().where( + chat_message.c.session_id == sid, + chat_message.c.id < bid, + ) + ) + ).scalar() + ) + def get_messages_after_id( self, *, session_id: str, after_id: int, limit: int ) -> list[ChatMessage]: diff --git a/svc/persistence/sqlite_store.py b/svc/persistence/sqlite_store.py index 9960f2a3..68b43366 100644 --- a/svc/persistence/sqlite_store.py +++ b/svc/persistence/sqlite_store.py @@ -2282,6 +2282,26 @@ class SqliteStore(ScheduledJobStoreMixin): session_id=sid, after_id=aid, limit=lim ) + def get_messages_before_id(self, *, session_id: str, before_id: int, limit: int = 200) -> list[ChatMessage]: + """Return up to ``limit`` messages with id < ``before_id``, ASC within the window.""" + sid = str(session_id or "").strip() + if not sid: + return [] + bid = int(before_id or 0) + if bid <= 0: + return [] + lim = max(1, min(int(limit), 2000)) + return self._chat_messages_repo().get_messages_before_id( + session_id=sid, before_id=bid, limit=lim + ) + + def exists_message_before_id(self, *, session_id: str, before_id: int) -> bool: + sid = str(session_id or "").strip() + bid = int(before_id or 0) + if not sid or bid <= 0: + return False + return self._chat_messages_repo().exists_message_before_id(session_id=sid, before_id=bid) + def add_tool_log( self, session_id: str, diff --git a/tests/test_sa_chat_messages.py b/tests/test_sa_chat_messages.py index 4d4b10ea..6760de3e 100644 --- a/tests/test_sa_chat_messages.py +++ b/tests/test_sa_chat_messages.py @@ -89,3 +89,19 @@ def test_sa_chat_message_tool_window_prepend(fresh_sqlite_store: SqliteStore) -> assert len(win) == 2 assert win[0].id == asst.id assert win[1].role == "tool" + + +def test_sa_chat_message_before_id_window(fresh_sqlite_store: SqliteStore) -> None: + s = fresh_sqlite_store + sess = s.create_session("P") + ids = [] + for i in range(6): + ids.append(s.add_message(sess.id, "user", f"m{i}").id) + recent = s.get_messages(sess.id, limit=2) + assert [m.content for m in recent] == ["m4", "m5"] + older = s.get_messages_before_id(session_id=sess.id, before_id=recent[0].id, limit=2) + assert [m.content for m in older] == ["m2", "m3"] + assert s.exists_message_before_id(session_id=sess.id, before_id=older[0].id) is True + head = s.get_messages_before_id(session_id=sess.id, before_id=ids[1], limit=10) + assert [m.content for m in head] == ["m0"] + assert s.exists_message_before_id(session_id=sess.id, before_id=head[0].id) is False