File size: 6,007 Bytes
3d700dd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | import { getLastRenderableEventId } from "#/hooks/chat/model-command-event-anchor";
import { useModelStore, SeededSwitch } from "#/stores/model-store";
import {
getStoredConversationMetadata,
mergeStoredConversationMetadata,
} from "#/api/conversation-metadata-store";
import { OpenHandsEvent } from "#/types/agent-server/core";
import { isSwitchLLMObservationEvent } from "#/types/agent-server/type-guards";
import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event";
export function recordModelSwitchMessage(
conversationId: string,
profileName: string,
anchorEventId: string | null = getLastRenderableEventId(),
) {
useModelStore
.getState()
.recordSwitch(conversationId, anchorEventId, profileName);
}
/**
* The active-profile stamp for a conversation: the optimistic in-memory entry
* (read first by the chat-input pill) plus the persisted per-conversation
* `active_profile` metadata (survives reloads, round-tripped onto the
* conversation by the agent-server adapter — issue #1082). Every path that
* learns of a successful switch — the user `/model` mutation, the live
* WebSocket handler, and the history seed below — must write through this one
* function so the live and reload paths can't drift.
*
* `stampedAt` is persisted alongside the profile so the history seed can tell
* a stale observation apart from a newer manual switch (see below). The live
* WebSocket handler passes the observation's own (server) timestamp; the user
* `/model` mutation has no event and defaults to the client clock.
*/
export function stampActiveLlmProfile(
conversationId: string,
profileName: string,
stampedAt: string = new Date().toISOString(),
) {
useModelStore.getState().setActiveProfile(conversationId, profileName);
// Merge rather than replace: the stamp only owns these two fields, and the
// record carries others it must not drop — the attached workspace mode
// (#15520), the plugins snapshot behind the in-conversation plugins view,
// and the local planner conversation id. Enumerating them here meant every
// new field had to remember to opt in.
mergeStoredConversationMetadata(conversationId, {
active_profile: profileName,
stamped_at: stampedAt,
});
}
/**
* Rebuilds the inline "Switched to" messages for a conversation from its loaded
* history.
*
* The live messages live in an in-memory store written only by the WebSocket
* handler and the user `/model` action. Existing conversations load via REST
* history, which bypasses that handler, so without this replay no past agent
* switches would render after a reload (the SwitchLLMObservation events are
* also hidden as cards by `shouldRenderEvent`).
*
* `uiEvents` MUST be the event store's `uiEvents` (the same list the renderer
* and the live `getLastRenderableEventId()` use) — NOT the raw history. The
* renderer anchors a message after an event only if that event's id is in
* `uiEvents.filter(shouldRenderEvent)`, and `uiEvents` differs from raw history:
* actions are replaced by their observations and `ThinkObservation` /
* `FinishObservation` are dropped. Anchoring off raw history would point at ids
* that never mount (e.g. a dropped `ThinkObservation`), orphaning the message.
*
* Each successful switch is anchored to the last renderable event before it,
* matching where the live handler would have placed it. Idempotent: entries are
* keyed by the observation event id, so re-seeding on every reload is a no-op.
*
* Also re-derives the active-profile stamp from the latest successful
* SwitchLLM observation. The stamp is otherwise written only by the live
* WebSocket handler and the user `/model` mutation, so a switch that fired
* while the socket was down would never be stamped — after a reload the pill
* showed the stale profile while the panel showed the server's true model.
*
* The re-stamp is gated on the stored `stamped_at`: manual `/model` switches
* go through REST and leave NO observation in history, so an unconditional
* re-stamp would roll a newer manual switch back to an older tool switch.
* The seed therefore only stamps when there is no existing stamp, the stamp
* predates this field (legacy — treated as older than any observation so it
* still gets repaired), or the latest observation is strictly newer.
* Observation timestamps are server-generated while `stamped_at` from the
* mutation path is client-generated, so the cross-clock comparison is a
* heuristic — acceptable here since the alternative is a silent rollback.
*/
export function seedModelSwitchesFromHistory(
conversationId: string,
uiEvents: OpenHandsEvent[],
) {
const switches: SeededSwitch[] = [];
let lastRenderableId: string | null = null;
let latestSwitch: { profileName: string; timestamp: string } | null = null;
for (const event of uiEvents) {
if (isSwitchLLMObservationEvent(event) && !event.observation.is_error) {
switches.push({
id: `history-switch:${event.id}`,
anchorEventId: lastRenderableId,
profileName: event.observation.profile_name,
});
latestSwitch = {
profileName: event.observation.profile_name,
timestamp: event.timestamp,
};
}
if (shouldRenderEvent(event)) {
lastRenderableId = String(event.id);
}
}
if (switches.length > 0) {
useModelStore.getState().seedSwitches(conversationId, switches);
}
if (latestSwitch) {
const { profileName, timestamp } = latestSwitch;
const stored = getStoredConversationMetadata(conversationId);
const stampedAt = stored?.active_profile ? stored.stamped_at : null;
if (!stampedAt || Date.parse(timestamp) > Date.parse(stampedAt)) {
// Stamp with the observation's own timestamp so re-seeding the same
// history on the next reload is a no-op (strictly-newer never matches).
stampActiveLlmProfile(conversationId, profileName, timestamp);
}
}
}
|