mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-16 06:40:45 +08:00
Paginate chat history so long sessions open quickly.
Load the latest 80 messages first and fetch older pages on scroll-up via before_id, instead of pulling thousands of rows up front. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2a52349668
commit
c3f3992a51
7 changed files with 256 additions and 17 deletions
|
|
@ -66,7 +66,8 @@ def _wiki_root_from_config() -> Path | None:
|
||||||
root = (Path(__file__).resolve().parents[2] / root).resolve()
|
root = (Path(__file__).resolve().parents[2] / root).resolve()
|
||||||
return root
|
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
|
_SESSION_TITLE_MAX_LEN = 120
|
||||||
_AVATAR_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
_AVATAR_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
||||||
_AVATAR_MIMES = frozenset({"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"})
|
_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(
|
def api_chat_messages(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
limit: int = Query(default=_CHAT_MSG_LIMIT, ge=1, le=20000),
|
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),
|
authorization: str | None = Header(default=None),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
store = get_assistant_store()
|
store = get_assistant_store()
|
||||||
|
|
@ -1066,11 +1068,24 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
||||||
if not sess:
|
if not sess:
|
||||||
raise HTTPException(status_code=404, detail="session_not_found")
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
meta = store.get_session_messages_meta(session_id)
|
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)
|
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 {
|
return {
|
||||||
"ok": True,
|
"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],
|
"messages": [_serialize_message(m) for m in msgs],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -781,7 +781,7 @@
|
||||||
<script defer src="/admin/assets/admin-theme.js"></script>
|
<script defer src="/admin/assets/admin-theme.js"></script>
|
||||||
<script
|
<script
|
||||||
defer
|
defer
|
||||||
src="/admin/assets/chat.js?v=20260811-vendor"
|
src="/admin/assets/chat.js?v=20260811-history-page"
|
||||||
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
|
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
|
||||||
></script>
|
></script>
|
||||||
<!-- Mermaid is larger; load after chat.js so login is not delayed by parse. -->
|
<!-- Mermaid is larger; load after chat.js so login is not delayed by parse. -->
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
/* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */
|
/* Standalone /chat page: same bearer + /admin/api/chat as admin SPA. */
|
||||||
|
|
||||||
const PAGE_SIZE = 35;
|
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 = {
|
const I18N = {
|
||||||
zh: {
|
zh: {
|
||||||
|
|
@ -73,7 +74,9 @@ const I18N = {
|
||||||
"chat.marker.summary": "文件标记:指针 {p},封套 {e}(封套内 {ep})",
|
"chat.marker.summary": "文件标记:指针 {p},封套 {e}(封套内 {ep})",
|
||||||
"chat.marker.ttl": "TTL:turn {t} / session {s} / keep {k}",
|
"chat.marker.ttl": "TTL:turn {t} / session {s} / keep {k}",
|
||||||
"chat.marker.reclaimed": "本轮回收 turn 标记 {n}",
|
"chat.marker.reclaimed": "本轮回收 turn 标记 {n}",
|
||||||
"chat.historyTruncated": "仅显示最近 {shown} 条,共 {total} 条",
|
"chat.historyTruncated": "已显示最近 {shown}/{total} 条 · 上滑加载更早",
|
||||||
|
"chat.loadingOlder": "正在加载更早消息…",
|
||||||
|
"chat.historyComplete": "已到最早消息",
|
||||||
"reasoning.summary": "模型推理片段",
|
"reasoning.summary": "模型推理片段",
|
||||||
"reasoning.processNotes": "主通道过程说明",
|
"reasoning.processNotes": "主通道过程说明",
|
||||||
"tool.streamTitle": "工具与进度(本轮)",
|
"tool.streamTitle": "工具与进度(本轮)",
|
||||||
|
|
@ -263,7 +266,9 @@ const I18N = {
|
||||||
"chat.marker.summary": "File markers: pointers {p}, envelope {e} (in-envelope {ep})",
|
"chat.marker.summary": "File markers: pointers {p}, envelope {e} (in-envelope {ep})",
|
||||||
"chat.marker.ttl": "TTL: turn {t} / session {s} / keep {k}",
|
"chat.marker.ttl": "TTL: turn {t} / session {s} / keep {k}",
|
||||||
"chat.marker.reclaimed": "Turn markers reclaimed {n}",
|
"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.summary": "Model reasoning",
|
||||||
"reasoning.processNotes": "Main-channel notes",
|
"reasoning.processNotes": "Main-channel notes",
|
||||||
"tool.streamTitle": "Tools & progress (this turn)",
|
"tool.streamTitle": "Tools & progress (this turn)",
|
||||||
|
|
@ -3085,7 +3090,13 @@ async function appendMessageRow(messagesEl, m, options = {}) {
|
||||||
else innerBubble.appendChild(bar);
|
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);
|
hydrateMermaidIn(bubble);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3414,6 +3425,9 @@ async function renderChatUi() {
|
||||||
};
|
};
|
||||||
messagesEl.addEventListener("scroll", () => {
|
messagesEl.addEventListener("scroll", () => {
|
||||||
shouldFollowMessages = isNearBottom();
|
shouldFollowMessages = isNearBottom();
|
||||||
|
if (messagesEl.scrollTop <= 80) {
|
||||||
|
loadOlderMessagesForActive().catch(() => {});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
messagesEl.addEventListener(
|
messagesEl.addEventListener(
|
||||||
"load",
|
"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 = {}) => {
|
const loadMessagesForActive = async (opts = {}) => {
|
||||||
loadMessagesForActive._rid = (loadMessagesForActive._rid || 0) + 1;
|
loadMessagesForActive._rid = (loadMessagesForActive._rid || 0) + 1;
|
||||||
const rid = loadMessagesForActive._rid;
|
const rid = loadMessagesForActive._rid;
|
||||||
shouldFollowMessages = true;
|
shouldFollowMessages = true;
|
||||||
|
historyHasMore = false;
|
||||||
|
historyOldestId = 0;
|
||||||
|
historyLoadedCount = 0;
|
||||||
|
historyTotal = 0;
|
||||||
|
historyLoadingOlder = false;
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
loadMessagesForActive._needsWsTextFallback = true;
|
loadMessagesForActive._needsWsTextFallback = true;
|
||||||
messagesEl.innerHTML = "";
|
messagesEl.innerHTML = "";
|
||||||
|
historyCapEl = null;
|
||||||
statusBar.textContent = sessions.length ? "" : t("chat.noSessions");
|
statusBar.textContent = sessions.length ? "" : t("chat.noSessions");
|
||||||
if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
|
if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -4327,7 +4377,10 @@ async function renderChatUi() {
|
||||||
const msgs = Array.isArray(resp.messages) ? resp.messages : [];
|
const msgs = Array.isArray(resp.messages) ? resp.messages : [];
|
||||||
const renderRows = _buildRenderRows(msgs);
|
const renderRows = _buildRenderRows(msgs);
|
||||||
loadMessagesForActive._needsWsTextFallback = _needsWsTextFallbackFromRenderRows(renderRows);
|
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
|
// 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
|
// 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.
|
// caused "stream flashes then entire dialog is empty" when reasoning/tool-output mode reloads.
|
||||||
|
|
@ -4336,14 +4389,8 @@ async function renderChatUi() {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
messagesEl.innerHTML = "";
|
messagesEl.innerHTML = "";
|
||||||
if (total > msgs.length) {
|
historyCapEl = null;
|
||||||
messagesEl.appendChild(
|
ensureHistoryCap();
|
||||||
el("div", {
|
|
||||||
class: "muted chat-msg-cap",
|
|
||||||
text: t("chat.historyTruncated", { shown: msgs.length, total }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!renderRows.length) {
|
if (!renderRows.length) {
|
||||||
messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
|
messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
|
||||||
} else {
|
} 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 () => {
|
const reloadSessionsOnly = async () => {
|
||||||
await refreshChannelPeerNameMap().catch(() => {});
|
await refreshChannelPeerNameMap().catch(() => {});
|
||||||
const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`);
|
const resp = await apiGet(`/admin/api/chat/sessions?limit=${PAGE_SIZE}&offset=0`);
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,8 @@ class AssistantStoreProtocol(Protocol):
|
||||||
def get_llm_profile_secret(self, profile_id: 'str') -> 'Optional[str]': ...
|
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(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_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(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_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]': ...
|
def get_secret(self, key: 'str') -> 'Optional[str]': ...
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,102 @@ class ChatMessagesSaRepository:
|
||||||
rows.insert(0, dict(arow))
|
rows.insert(0, dict(arow))
|
||||||
return [_row_to_chat_message(r) for r in rows]
|
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(
|
def get_messages_after_id(
|
||||||
self, *, session_id: str, after_id: int, limit: int
|
self, *, session_id: str, after_id: int, limit: int
|
||||||
) -> list[ChatMessage]:
|
) -> list[ChatMessage]:
|
||||||
|
|
|
||||||
|
|
@ -2282,6 +2282,26 @@ class SqliteStore(ScheduledJobStoreMixin):
|
||||||
session_id=sid, after_id=aid, limit=lim
|
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(
|
def add_tool_log(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,19 @@ def test_sa_chat_message_tool_window_prepend(fresh_sqlite_store: SqliteStore) ->
|
||||||
assert len(win) == 2
|
assert len(win) == 2
|
||||||
assert win[0].id == asst.id
|
assert win[0].id == asst.id
|
||||||
assert win[1].role == "tool"
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue