mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
feat(persistence): PostgreSQL assistant store, chat persist fixes, gateway scripts
- Add SQLAlchemy Core repos, pg adapter/compat, assistant_store factory, Alembic bootstrap and migration/cutover scripts. - Harden chat_message writes (NUL scrub for PG), turn_uuid on attempt failure, WS turn_runner fallbacks and gateway executed_turn_uuid init. - start_gateway: log paths, PS7 stderr handling via cmd, background stdout/stderr redirect; runtime assistant_runtime_log_dir export. - Ops: clear_all_chat_sessions with PG-only --postgresql and env-gated wipe; clear_postgres_chat_sessions.ps1. - Tests: SA repos, pg compat, persist fallback, smoke env isolation; CI and docs touch-ups. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2b32d11f43
commit
d14e9d3596
103 changed files with 7574 additions and 1641 deletions
|
|
@ -688,6 +688,28 @@ function _buildRenderRows(msgs) {
|
|||
return rows;
|
||||
}
|
||||
|
||||
/** True when persisted history does not show a completed assistant reply for the latest turn (e.g. ends with user only). */
|
||||
function _needsWsTextFallbackFromRenderRows(renderRows) {
|
||||
const rows = Array.isArray(renderRows) ? renderRows : [];
|
||||
if (!rows.length) return true;
|
||||
const last = rows[rows.length - 1];
|
||||
const lr = String((last && last.role) || "").toLowerCase();
|
||||
if (lr === "user") return true;
|
||||
if (lr !== "assistant") return true;
|
||||
const items = last && Array.isArray(last._items) ? last._items : [];
|
||||
for (const it of items) {
|
||||
const k = String((it && it.kind) || "").toLowerCase();
|
||||
if (k === "assistant_text" && String((it && it.text) || "").trim()) return false;
|
||||
if (k === "reasoning" && String((it && it.text) || "").trim()) return false;
|
||||
if (k === "tool_result") {
|
||||
if (String((it && it.text) || "").trim()) return false;
|
||||
if (Array.isArray(it.attachments) && it.attachments.length) return false;
|
||||
}
|
||||
}
|
||||
if (String((last && last.content) || "").trim()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function t(key, vars) {
|
||||
let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en && I18N.en[key]) || key;
|
||||
if (vars && typeof s === "string") {
|
||||
|
|
@ -3739,25 +3761,35 @@ async function renderChatUi() {
|
|||
},
|
||||
};
|
||||
|
||||
const loadMessagesForActive = async () => {
|
||||
const loadMessagesForActive = async (opts = {}) => {
|
||||
loadMessagesForActive._rid = (loadMessagesForActive._rid || 0) + 1;
|
||||
const rid = loadMessagesForActive._rid;
|
||||
shouldFollowMessages = true;
|
||||
if (!activeId) {
|
||||
loadMessagesForActive._needsWsTextFallback = true;
|
||||
messagesEl.innerHTML = "";
|
||||
statusBar.textContent = sessions.length ? "" : t("chat.noSessions");
|
||||
if (!sessions.length) messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
statusBar.textContent = t("chat.loading");
|
||||
loadMessagesForActive._needsWsTextFallback = true;
|
||||
try {
|
||||
const resp = await apiGet(
|
||||
`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/messages?limit=${CHAT_MESSAGES_FETCH_LIMIT}`,
|
||||
);
|
||||
if (rid !== loadMessagesForActive._rid) return;
|
||||
if (rid !== loadMessagesForActive._rid) return 0;
|
||||
const msgs = Array.isArray(resp.messages) ? resp.messages : [];
|
||||
const renderRows = _buildRenderRows(msgs);
|
||||
loadMessagesForActive._needsWsTextFallback = _needsWsTextFallbackFromRenderRows(renderRows);
|
||||
const total = intOr(resp.message_count, msgs.length);
|
||||
// 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.
|
||||
if (!renderRows.length && opts.keepDomIfNoHistoryRows) {
|
||||
statusBar.textContent = "";
|
||||
return 0;
|
||||
}
|
||||
messagesEl.innerHTML = "";
|
||||
if (total > msgs.length) {
|
||||
messagesEl.appendChild(
|
||||
|
|
@ -3776,10 +3808,13 @@ async function renderChatUi() {
|
|||
}
|
||||
statusBar.textContent = "";
|
||||
scrollMessagesToBottom(true);
|
||||
return renderRows.length;
|
||||
} catch (e) {
|
||||
// Keep existing message list on reload failure (e.g. toggle reload races),
|
||||
// so users don't perceive "messages disappeared".
|
||||
loadMessagesForActive._needsWsTextFallback = true;
|
||||
statusBar.textContent = `${t("chat.error")}: ${String(e)}`;
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -4847,25 +4882,39 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
renderStreamComposite();
|
||||
_markStreamTerminal("end", t("chat.status.end"));
|
||||
ok = true;
|
||||
} else if (adminChatShowToolOutput) {
|
||||
// WS final message may only contain the last assistant_text snapshot and
|
||||
// omit persisted reasoning rows. Hydrate from history to avoid
|
||||
// end-of-turn "reasoning disappears until refresh".
|
||||
try {
|
||||
if (streamRow && streamRow.parentNode) streamRow.remove();
|
||||
} catch (_) {}
|
||||
await loadMessagesForActive();
|
||||
} else if (adminChatShowToolOutput || sawStreamToolRefAttachments) {
|
||||
// Hydrate from history for reasoning / tool panels / ref attachments. If the DB has not
|
||||
// yet persisted the assistant reply (or only has the user row), n>0 used to skip
|
||||
// appendFinalAssistant and removed the stream bubble — leaving an empty pane like the
|
||||
// user screenshot. Use _needsWsTextFallback when WS still holds usable text.
|
||||
const hadStream =
|
||||
hasRealStreamText ||
|
||||
(Array.isArray(chatStreamSegments) && chatStreamSegments.length > 0) ||
|
||||
!!String(chatStream || "").trim();
|
||||
const fbLine = chatStream || extractWsAssistantText(payload.message || {});
|
||||
const fbTrim = String(fbLine || "").trim();
|
||||
const fbOk = !!fbTrim && !_isSilentReplyStream(fbTrim);
|
||||
const n = await loadMessagesForActive({ keepDomIfNoHistoryRows: hadStream });
|
||||
const needWsFallback =
|
||||
hadStream && fbOk && (n < 0 || !!loadMessagesForActive._needsWsTextFallback);
|
||||
if (n >= 0) {
|
||||
try {
|
||||
if (streamRow && streamRow.parentNode) streamRow.remove();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (needWsFallback) {
|
||||
if (n < 0) {
|
||||
try {
|
||||
if (streamRow && streamRow.parentNode) streamRow.remove();
|
||||
} catch (_) {}
|
||||
}
|
||||
ok = await appendFinalAssistant(payload.message, fbLine);
|
||||
} else if (n > 0) {
|
||||
ok = true;
|
||||
} else {
|
||||
ok = n === 0;
|
||||
}
|
||||
scrollMessagesToBottom(true);
|
||||
ok = true;
|
||||
} else if (sawStreamToolRefAttachments) {
|
||||
// Streaming UI cannot render image_ref/relay_pointer; recover from persisted history so images appear
|
||||
// without requiring a manual refresh.
|
||||
try {
|
||||
if (streamRow && streamRow.parentNode) streamRow.remove();
|
||||
} catch (_) {}
|
||||
await loadMessagesForActive();
|
||||
scrollMessagesToBottom(true);
|
||||
ok = true;
|
||||
} else {
|
||||
ok = await appendFinalAssistant(payload.message, chatStream || extractWsAssistantText(payload.message || {}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue