mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
* feat: add unseen reply indicators, reply notifications, and mark as unread
A reply that lands while the user is elsewhere now leaves a trace. The
conversation carries a durable reply stamp written with the assistant message
that backs it, the sidebar row shows an unread dot until that message is
actually rendered, and an optional tab badge, desktop notification and chime
announce arrivals in a tab that is away. Rows can also be marked unread by hand
from the row menu, in the archived view as well as the main list.
The stamp is written by a compare-and-swap so concurrent replies order on the
database rather than on the application hosts' clocks, and only once output that
a reader can actually see is persisted: a stopped turn with nothing to read, an
empty synchronized row, or a failed message write never lights a dot that cannot
be cleared. Read intent survives archives, metadata edits and preset applies,
and a later read always outranks a delayed unread.
* fix: gate reply alerts on config and announce only readable replies
Three review rounds kept finding the same two shapes, so both are answered once
rather than patched again.
The alert capabilities are now operator-configurable through
`interface.replyNotifications` in `librechat.yaml`: the tab badge, desktop
notifications and the chime each have a gate, and the away poll's page size is a
field rather than a constant, with defaults that reproduce the shipped behavior.
A capability the operator turns off reads as off whatever the device stored, and
its settings toggle is not offered.
Deciding whether a persisted turn may raise an indicator now lives in one place
in `packages/api`, with the Responses API controller, the abort route, the
Assistants thread sync and the client's override path all calling it instead of
carrying their own copy. That closes the last case where a reply nobody can read
lit a dot nothing could clear: a `/v1/responses` completion whose output held
only reasoning or tool calls persists an empty row, and stamping it left an
unread conversation that opening could not settle.
Also: the retention backfill no longer advances `updatedAt` past a reply stamp,
which an away tab reads as a metadata-only promotion and withholds the alert
for; arrival evidence is swept of conversations no cached list still holds, so a
long-lived tab stops growing with its lifetime number of replies; and generated
Lighthouse profile directories are ignored where the runner leaves them.
* test: open the sidebar by what the drawer paints, not by its test ids
The drawer became a mounted, sliding panel on canary, so the controls this
helper read no longer say what it assumed. `close-sidebar-button` answers from
the off-canvas copy before the expanded state commits, which returned the helper
with the conversation list still hidden behind the pane, and the panel's own
`new-chat-button` has a rail copy that a plain locator reaches first. Both
readings passed on the previous drawer, which unmounted when closed.
Settling on the close control's accessible name, and on the first candidate that
is actually painted, is what a reader perceives and survives either drawer. One
click is one toggle and the opener sits in the pane the drawer marks inert for
the whole of its travel, so each attempt is given that travel to itself rather
than reissued into it.
Also serves `interface.replyNotifications` in the operator-gate scenario with a
fresh body instead of the upstream response, since reusing its encoding headers
for a re-serialized payload leaves the client unable to read the config, which
looks exactly like an operator who configured nothing.
* fix: gate the assistant save on renderable output and stop overselling the poll limit
Two gaps this round's own work left behind.
`saveAssistantMessage` still decided to stamp from the message id alone, so an
Assistants run that persisted an empty row raised a dot that opening the
conversation could never clear. It now asks `isAnnounceableReply`, the same
predicate every other persistence path asks, which is the point of having moved
that decision into one place.
`pollLimit` advertised a range up to 1000 while `getConvosByCursor` clamps every
conversation read to a hundred-row page, so an operator who set 500 would have
been told it applied and silently served a hundred. The schema now stops at the
page the server will actually return; covering a deployment whose replies
outpace one page needs the server-side unseen query, not a wider page.
* test: let the mobile scenarios use the surface they are actually asserting on
Three scenarios opened the drawer on a page they only ever send a reply from.
On the mobile project that drawer sits over the composer and marks the pane
inert, so there was nothing left to type in and the run waited out its whole
budget. They ask for the composer now, which is what they were always about.
The two that read a row after leaving the list open it again first. A reply sent
from another tab closes this one's drawer, because `sidebarExpanded` is stored
per browser rather than per tab, and opening a chat closes it the way it does
for a reader. Both are what someone would do to look at the row, and neither
changes what is being asserted.
Mark as unread is dispatched rather than clicked, like the row controls around
it: the menu is portaled, and behind the drawer's scrim it inherits
`pointer-events: none` and goes click-dead.
* test: dispatch the row menu's unread item like the controls around it
The portaled menu sits under the mobile drawer's scrim, which intercepts the
pointer, so a real click retried until the budget ran out. The sibling scenarios
and the row's own controls already dispatch; these two were the last real
clicks.
* fix: hold reply alerts until the deployment answers, and read the resolved temporary state
The capability gate treated a startup config that had not loaded yet the same as
one that set nothing, so a device that stored "on" could badge, poll or notify in
the window before an operator's `false` arrived. Nothing is permitted now until
the config is in hand; only a loaded config that lacks the field reads as the
shipped default, which is the case a backend predating the setting produces.
The Responses API announcement read the temporary flag from the request body
alone, while the conversation save beside it resolves it from the stored
conversation first. A temporary chat restored or resolved on the server without
the flag in the body would have been announced through the unread indicators;
both completion branches now resolve it the same way the save does.
* fix: route the last reply stamps through the shared owner and hand back failed claims
Moving the announce decision into packages/api left three callers still deciding
for themselves. The agents controller re-stamped an unfinished turn whose
terminal persistence was skipped, and the resumed legacy turn stamped its
directly saved row, both from the message id alone, so a preempted or stopped
turn that persisted nothing readable lit a dot that could never be
acknowledged. Both ask isAnnounceableReply now. The non-agents abort path
carried an inline copy of the same predicate; it calls announceReply instead.
Error turns keep their direct stamp: the error card is what renders them.
A notification claim was written to shared storage before the notification
was constructed, so a constructor that throws left the reply claimed in every
tab with nothing ever shown. The claim is handed back when construction fails,
removed only while it still names that exact stamp.
* fix: judge the normal reply save by the shared predicate and hand back failed chimes
The regular BaseClient save still stamped any assistant reply that persisted,
readable or not, because the conversation write took a bare reply id. It takes
the persisted reply now and asks isAnnounceableReply before it stamps, which
leaves the error turns as the only direct stamps, and those are rendered by the
error card. saveTurnConversation is exercised against a real store in both
directions. announce.ts derives its write-context type locally so the two
modules do not import each other.
The chime claimed its replies before building the tones, so an output that
failed while they were being scheduled left them claimed in every tab with
nothing played. Those claims are handed back on failure, the same way a
notification that could not be constructed now releases its own.
* feat: let operators set the reply polling cadence
The away poll and the focused refresh were fixed at 30 seconds and five minutes,
which are request-rate levers on the conversation list and nothing an operator
could tune. Both are fields on `interface.replyNotifications` now, bounded and
defaulting to the values they had, and the watcher reads them through the same
capability hook as the rest of the block.
* fix: keep reply identity through list merges and drop rows the server no longer has
The list merge carried three of the four read-state fields by hand and left out
`lastResponseMessageId`, so an SSE update on an unseen conversation erased which
branch the reply landed on, and a missing identity reads as visible. It uses the
same `preserveReadState` helper as the other two merge paths now, and the test
that covered the other three fields covers this one too.
A local cache merge was only inspected on a list's first page for new arrivals.
A title or oldest-first sort leaves a replied row where it is, so its first
reply on a later page went into the alerts baseline without an arrival and its
chime or notification was lost. Local merges scan every loaded page; server
responses keep the first-page rule, and only rows already known are considered.
An unread request that matched nothing means the conversation is gone: the
server's owner-scoped update returns the row even for a no-op. The mutation
rolled back the read fields and kept the row; it removes it from the caches now,
as a delete does.
* fix: discard a job completion that resolves after the watcher has gone
The fetch that settles a finished job had no teardown guard, unlike the away
poll. Signing out or switching accounts unmounts the watcher, and a response
still in flight for the previous session would then merge into the caches the
next one reads. The result is dropped once the watcher has unmounted, tracked
on unmount rather than in the jobs effect's cleanup, which re-runs on every
change to the running set while its earlier completions are still wanted.
* fix: move error-turn stamps into packages/api and settle overlapping unread no-matches
Error turns were stamped in CJS: the error middleware checked eligibility, called
the stamp and shaped the event's conversation snapshot itself, and the agents
controller stamped its own. Both call announceErrorTurn now, and the
skipped-persistence restamp goes through announceReply, so no stamp is issued
directly from /api any more. The existing sendError tests pass against the real
helper unchanged.
An unread call that no longer owned the row dropped a server no-match with the
rest of its stale answers, so a deleted conversation could be restored by a newer
call's rollback. The no-match now evicts before ownership is consulted. It stays
after the superseded check: a superseded call never reached the server, and its
synthetic `modified: false` is not a no-match.
Also: Settings offers the reply toggles only once the startup config has loaded,
matching the capability hook; a cancelled reply-discovery snapshot is restarted
even though nothing observes it; and the focused refresh is capped at the five
minutes the discovery snapshot stays authoritative, since that refresh is what
renews it.
* fix: settle the assistant final in packages/api, announce attachment-only replies
Both assistant controllers carried the same persistence barrier: write the
response, check the row and the conversation both persisted, and unwrap the
settled conversation for the final event. That is behaviour, and it was
duplicated in CJS; settleAssistantFinal owns it now and the controllers call
it. Their existing tests pass unchanged.
The announce predicate ignored attachments, so a reply made only of files,
such as a resumed workspace artifact with empty content and text, raised no
dot, badge or alert. The message body renders attachments and acknowledgement
checks the body, so such a reply can be cleared as well as announced; every
announce path now passes them through.
The stopped-reply announcement took the storage engine's own id type in its
exported signature. It takes plain string ids now, and saveConvo accepts them
alongside its own, since the update casts them against the schema; the id type
stays inside data-schemas.
* fix: normalize the stopped reply's written ids in packages/api
The abort route filtered and stringified the ids of the rows it had written
before handing them to announceStoppedReply. That is data shaping, and it sat
in CJS; the announcement takes the ids as the caller holds them and drops the
unwritten ones itself.
* style: order the conversation row classes for Tailwind v4
The v4 upgrade on canary changed the class order Prettier enforces; these
three rows carried the v3 order.
* fix: replay replies a dead tab's focus lease held back, keep the focused refresh at a full page
A tab killed without firing blur or pagehide leaves its focus lease for up to
a minute. A reply arriving under it was baselined and then suppressed, and
nothing reran when the lease lapsed, so its chime and notification were lost.
Arrivals held back only by another tab's lease are kept and rechecked when
that lease expires; a lease the focused tab keeps refreshing still holds them.
The focused refresh reused the away-poll limit, so an operator who lowered it
also shrank the discovery page that keeps the unseen count complete behind a
filtered list. The limit now bounds only the away poll.
* fix: announce a held reply as soon as the focused tab releases its lease
A tab that blurs normally clears its lease long before the lapse, and a reply
held back for it is due as soon as nobody is looking. Lease changes from other
tabs now trigger the recheck, not only the expiry timer.
(cherry picked from commit b947738d0d)
Original-PR: https://github.com/LibreChat-AI/LibreChat/pull/15025
130 lines
3.8 KiB
JavaScript
130 lines
3.8 KiB
JavaScript
const crypto = require('crypto');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { parseConvo } = require('librechat-data-provider');
|
|
const {
|
|
sendEvent,
|
|
handleError,
|
|
announceErrorTurn,
|
|
sanitizeMessageForTransmit,
|
|
} = require('@librechat/api');
|
|
const { saveMessage, getMessages, getConvo, stampConvoLastResponse } = require('~/models');
|
|
|
|
/**
|
|
* Processes an error with provided options, saves the error message and sends a corresponding SSE response
|
|
* @async
|
|
* @param {object} req - The request.
|
|
* @param {object} res - The response.
|
|
* @param {object} options - The options for handling the error containing message properties.
|
|
* @param {object} options.user - The user ID.
|
|
* @param {string} options.sender - The sender of the message.
|
|
* @param {string} options.conversationId - The conversation ID.
|
|
* @param {string} options.messageId - The message ID.
|
|
* @param {string} options.parentMessageId - The parent message ID.
|
|
* @param {string} options.text - The error message.
|
|
* @param {boolean} options.shouldSaveMessage - [Optional] Whether the message should be saved. Default is true.
|
|
* @param {function} callback - [Optional] The callback function to be executed.
|
|
*/
|
|
const sendError = async (req, res, options, callback) => {
|
|
const {
|
|
user,
|
|
sender,
|
|
conversationId,
|
|
messageId,
|
|
parentMessageId,
|
|
text,
|
|
shouldSaveMessage,
|
|
...rest
|
|
} = options;
|
|
const errorMessage = {
|
|
sender,
|
|
messageId: messageId ?? crypto.randomUUID(),
|
|
conversationId,
|
|
parentMessageId,
|
|
unfinished: false,
|
|
error: true,
|
|
final: true,
|
|
text,
|
|
isCreatedByUser: false,
|
|
...rest,
|
|
};
|
|
if (callback && typeof callback === 'function') {
|
|
await callback();
|
|
}
|
|
|
|
if (shouldSaveMessage) {
|
|
const isTemporary = req?.resolvedConversation?.isTemporary ?? req?.body?.isTemporary;
|
|
const savedError = await saveMessage(
|
|
{
|
|
userId: req?.user?.id,
|
|
isTemporary,
|
|
expiredAt: req?.resolvedConversation?.expiredAt,
|
|
interfaceConfig: req?.config?.interfaceConfig,
|
|
},
|
|
{ ...errorMessage, user },
|
|
{
|
|
context: 'api/server/utils/streamResponse.js - sendError',
|
|
},
|
|
);
|
|
const conversation = await announceErrorTurn(
|
|
{ stampConvoLastResponse },
|
|
{
|
|
userId: req?.user?.id ?? user,
|
|
conversationId,
|
|
messageId: savedError?.messageId,
|
|
isTemporary: isTemporary === true,
|
|
context: 'sendError',
|
|
},
|
|
);
|
|
if (conversation) {
|
|
errorMessage.conversation = conversation;
|
|
}
|
|
}
|
|
|
|
if (!errorMessage.error) {
|
|
const requestMessage = { messageId: parentMessageId, conversationId };
|
|
let query = [],
|
|
convo = {};
|
|
try {
|
|
query = await getMessages(requestMessage);
|
|
convo = await getConvo(user, conversationId);
|
|
} catch (err) {
|
|
logger.error('[sendError] Error retrieving conversation data:', err);
|
|
convo = parseConvo(errorMessage);
|
|
}
|
|
|
|
return sendEvent(res, {
|
|
final: true,
|
|
requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
|
|
responseMessage: errorMessage,
|
|
conversation: convo,
|
|
});
|
|
}
|
|
|
|
handleError(res, errorMessage);
|
|
};
|
|
|
|
/**
|
|
* Sends the response based on whether headers have been sent or not.
|
|
* @param {ServerRequest} req - The server response.
|
|
* @param {Express.Response} res - The server response.
|
|
* @param {Object} data - The data to be sent.
|
|
* @param {string} [errorMessage] - The error message, if any.
|
|
*/
|
|
const sendResponse = (req, res, data, errorMessage) => {
|
|
if (!res.headersSent) {
|
|
if (errorMessage) {
|
|
return res.status(500).json({ error: errorMessage });
|
|
}
|
|
return res.json(data);
|
|
}
|
|
|
|
if (errorMessage) {
|
|
return sendError(req, res, { ...data, text: errorMessage });
|
|
}
|
|
return sendEvent(res, data);
|
|
};
|
|
|
|
module.exports = {
|
|
sendError,
|
|
sendResponse,
|
|
};
|