mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-19 01:37:03 +00:00
Some checks are pending
L10n / l10n (push) Waiting to run
* Fix ten reported issues and vendor Hermes Agent as an API reference Closes #693, #695, #699, #700, #701, #703, #708, #712, #716, #723. - Hermes (#693, #699): load transcripts over REST even when session.resume fails, page at 200 messages and shrink on oversized responses, and show distinct errors for too-large, not-found, and RPC failures. Add the hermes-src submodule (NousResearch/hermes-agent v2026.9.11) as the RPC and REST contract reference. - Server version banner (#695): dismissible, remembered per server and version. - Model selector (#700): overflow fade and selection tint use the card surface. - OpenRouter (#701): skip Batch API-only ":batch" model variants. - Persisted chat content (#703): store plain output text instead of rendered <details> markup when structured output exists, matching the web client. - Direct connections (#708): probes report their real failure instead of a generic message, the Open WebUI logout fence no longer blocks Direct setup, and 401/403 under the api-key header mode hints at Bearer auth. - Reasoning effort (#712): per-server, per-model user picks override the model's configured default and are sent explicitly in the request. - Voice calls (#716): stop the record plugin from clearing the Android communication device and verify the loudspeaker route before reporting it. - Voice mode (#723): finalize speech on the responseDone marker. - Add the five new strings to every locale. * Address review: keep incomplete-clear block, pass effort on sends, keep all dismissals - Direct profiles stay hidden and unusable after an incomplete full app-data clear, in memory and across restarts, until the logout fence is cleared by a completed cleanup or a new authenticated session. Ordinary failed sign-outs still do not block Direct setup. - The primary Open WebUI send path now passes the resolved per-model reasoning effort like regeneration and queued completions already did. - Server version warning dismissals are stored as a bounded set so dismissing one server no longer forgets another. * Address review: release rejected speaker device, speak settled text on responseDone - When Android accepts the built-in speaker but the read-back disagrees, clear that selection before the legacy setSpeakerphoneOn fallback, which is ignored while a communication device stays selected. - Voice mode takes the final text from the message once responseDone is set; the streaming content provider can lag behind the flushed message. * Address review: await the incomplete-clear restart marker before returning The marker is written through the app-data-clear barrier with a checked write and awaited by the clear coordinator, so a process death right after an incomplete clear cannot reload the surviving Direct profiles on restart. * Address review: arm the restart fence before the wipe and propagate marker failures The incomplete-clear marker is now armed before any data is wiped, disarmed only when the clear completes or yields ownership, re-asserted on an incomplete outcome, and a failed checked write aborts the clear instead of being swallowed. The in-memory block is unchanged. * Do not raise an error banner for response.incomplete Open WebUI forwards the provider's `response.incomplete` event over the socket but treats it as a no-op: the output is kept and the turn still ends with a normal `done`. Raising an error on that event flashed "The response stopped before it was complete" under the answer until `done` cleared it. `response.failed` still surfaces the provider error.
604 lines
21 KiB
Dart
604 lines
21 KiB
Dart
part of 'hermes_desktop_api_service.dart';
|
|
|
|
extension _HermesDesktopLiveRuntime on HermesDesktopApiService {
|
|
Future<void> _ensureConnected() async {
|
|
if (_closed) throw StateError('Hermes service is closed.');
|
|
if (_rpc.isReady) return;
|
|
return _connecting ??= _connect().whenComplete(() => _connecting = null);
|
|
}
|
|
|
|
Future<void> _connect() async {
|
|
_emitTurnState(HermesDesktopTurnState.reconnecting);
|
|
final status = await statusProbe(refresh: true);
|
|
if (_closed) throw StateError('Hermes service is closed.');
|
|
final query = <String, String>{};
|
|
if (status['auth_required'] == true) {
|
|
final ticketData = await _requestJson('POST', '/api/auth/ws-ticket');
|
|
if (_closed) throw StateError('Hermes service is closed.');
|
|
final ticket = ticketData is Map
|
|
? ticketData['ticket']?.toString()
|
|
: null;
|
|
if (ticket == null || ticket.isEmpty) {
|
|
throw StateError('Hermes did not issue a WebSocket ticket.');
|
|
}
|
|
query['ticket'] = ticket;
|
|
} else {
|
|
final token = config.desktopCredentials?.legacyToken;
|
|
if (token == null || token.isEmpty) {
|
|
throw StateError('Hermes requires a legacy session token.');
|
|
}
|
|
query['token'] = token;
|
|
}
|
|
final httpUri = _uri('/api/ws').replace(queryParameters: query);
|
|
final wsUri = httpUri.replace(
|
|
scheme: httpUri.scheme == 'https' ? 'wss' : 'ws',
|
|
);
|
|
await _rpc.connect(
|
|
wsUri,
|
|
headers: config.accessHeaders,
|
|
httpClient: hermesTlsHttpClient(config),
|
|
);
|
|
if (_closed) {
|
|
await _rpc.disconnect();
|
|
return;
|
|
}
|
|
_stateSubscription ??= _rpc.events.listen((event) {
|
|
final startedBuffering = _eventBuffer.add(event);
|
|
if (event.type == 'session.info') {
|
|
// Update BOTH keys. `session.create` can omit `running`, which parks
|
|
// the stored id at unsupportedGateway; a runtime-only update never
|
|
// clears that, so the second turn onward fails the safety gate with
|
|
// "Hermes run failed." until a resume repairs the state.
|
|
_applyAuthoritativeRunning(
|
|
event.payload['running'],
|
|
storedId: _storedIdForRuntime(event.sessionId),
|
|
runtimeId: event.sessionId,
|
|
);
|
|
}
|
|
if (event.type == 'approval.request' ||
|
|
event.type == 'clarify.request' ||
|
|
event.type == 'sudo.request' ||
|
|
event.type == 'secret.request' ||
|
|
event.type == 'mcp.setup.request') {
|
|
unawaited(_persistPendingDecisionEvent(event));
|
|
}
|
|
if (event.type == 'approval.responded' ||
|
|
event.type == 'approval.expire' ||
|
|
event.type == 'clarify.expire' ||
|
|
event.type == 'sudo.expire' ||
|
|
event.type == 'secret.expire' ||
|
|
event.type == 'mcp.setup.expire') {
|
|
unawaited(_resolvePendingDecisionEvent(event));
|
|
}
|
|
if (startedBuffering &&
|
|
(event.type == 'message.complete' ||
|
|
event.type.endsWith('.request') ||
|
|
(event.type == 'session.info' &&
|
|
event.payload['running'] == false))) {
|
|
final storedId = _storedIdForRuntime(event.sessionId);
|
|
if (storedId != null) _emitTranscriptChange(storedId);
|
|
}
|
|
});
|
|
_emitTurnState(HermesDesktopTurnState.idle);
|
|
}
|
|
|
|
Future<void> _resolvePendingDecisionEvent(HermesDesktopEvent event) async {
|
|
final runtimeId = validateHermesOpaqueIdentifier(event.sessionId);
|
|
final requestId = validateHermesOpaqueIdentifier(
|
|
event.payload['request_id'] ?? event.payload['id'],
|
|
sensitiveValues: config.sensitiveValues,
|
|
);
|
|
if (runtimeId == null || requestId == null) return;
|
|
await HermesPendingDecisionStore.resolve(
|
|
origin: _origin,
|
|
runtimeId: runtimeId,
|
|
requestId: requestId,
|
|
);
|
|
}
|
|
|
|
void _runtimeDidChangeAppLifecycleState(AppLifecycleState state) {
|
|
switch (state) {
|
|
case AppLifecycleState.paused:
|
|
case AppLifecycleState.hidden:
|
|
case AppLifecycleState.detached:
|
|
// The OS may keep the socket alive. Reconcile on return instead of
|
|
// closing a valid transport merely because the app backgrounded.
|
|
_reconciliationStale = true;
|
|
case AppLifecycleState.resumed:
|
|
_foregroundReconciliation ??= _reconcileForeground().whenComplete(
|
|
() => _foregroundReconciliation = null,
|
|
);
|
|
case AppLifecycleState.inactive:
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _reconcileForeground() async {
|
|
if (!_reconciliationStale || _closed) return;
|
|
_emitTurnState(HermesDesktopTurnState.synchronizing);
|
|
final storedIds = _bindings.keys.toList(growable: false);
|
|
try {
|
|
await statusProbe(refresh: true);
|
|
if (_closed) return;
|
|
if (_rpc.isReady) {
|
|
if (storedIds.isEmpty) {
|
|
await _rpc.request<Object?>(
|
|
'model.options',
|
|
timeout: const Duration(seconds: 10),
|
|
);
|
|
} else {
|
|
for (final storedId in storedIds) {
|
|
await _resume(storedId, refresh: true);
|
|
_emitTranscriptChange(storedId);
|
|
}
|
|
}
|
|
} else {
|
|
_bindings.clear();
|
|
_bindingSocketGenerations.clear();
|
|
_freshSessionIds.clear();
|
|
await _ensureConnected();
|
|
for (final storedId in storedIds) {
|
|
await _resume(storedId);
|
|
_emitTranscriptChange(storedId);
|
|
}
|
|
}
|
|
_reconciliationStale = false;
|
|
} catch (_) {
|
|
await _rpc.disconnect();
|
|
_bindings.clear();
|
|
_bindingSocketGenerations.clear();
|
|
_freshSessionIds.clear();
|
|
_emitTurnState(HermesDesktopTurnState.reconnecting);
|
|
}
|
|
}
|
|
|
|
void _applyAuthoritativeRunning(
|
|
Object? running, {
|
|
String? storedId,
|
|
String? runtimeId,
|
|
}) {
|
|
final state = running is! bool
|
|
? HermesDesktopTurnState.unsupportedGateway
|
|
: running
|
|
? HermesDesktopTurnState.running
|
|
: HermesDesktopTurnState.idle;
|
|
if (running is! bool) {
|
|
_authoritativeRunning = false;
|
|
} else {
|
|
_authoritativeRunning = true;
|
|
}
|
|
for (final id in <String?>{storedId, runtimeId}) {
|
|
if (id == null || id.isEmpty) continue;
|
|
_sessionTurnStates[id] = state;
|
|
_emitSessionTurnState(id);
|
|
}
|
|
_emitTurnState(state);
|
|
}
|
|
|
|
Map<String, dynamic> _object(Object? value) =>
|
|
value is Map ? Map<String, dynamic>.from(value) : <String, dynamic>{};
|
|
|
|
List<Map<String, dynamic>> _objects(Object? value, [String? envelope]) {
|
|
final source = envelope != null && value is Map ? value[envelope] : value;
|
|
if (source is! List) return const [];
|
|
return source
|
|
.whereType<Map>()
|
|
.take(10000)
|
|
.map((item) => Map<String, dynamic>.from(item))
|
|
.toList();
|
|
}
|
|
|
|
Future<HermesSessionBinding> _resume(
|
|
String storedId, {
|
|
bool refresh = false,
|
|
}) async => (await _resumeSnapshot(storedId, refresh: refresh)).binding;
|
|
|
|
/// The profile that owns [storedId] — a Bot Mode chat's own profile, or the
|
|
/// connection's configured one.
|
|
///
|
|
/// Always explicit, never empty. The RPC transport injects the connection
|
|
/// profile as a default, so omitting this does not mean "let the server
|
|
/// decide": it means a request about a BOT session travels naming the
|
|
/// CONNECTION's profile, and any handler that reads it resolves against the
|
|
/// wrong profile's config and state.
|
|
Map<String, dynamic> _sessionScope(String storedId) => {
|
|
'profile': _sessionProfiles[storedId] ?? config.desktopProfile,
|
|
};
|
|
|
|
Future<({HermesSessionBinding binding, bool? running})> _resumeSnapshot(
|
|
String storedId, {
|
|
bool refresh = false,
|
|
}) async {
|
|
final existing = _bindings[storedId];
|
|
if (!refresh &&
|
|
existing != null &&
|
|
_rpc.isReady &&
|
|
_bindingSocketGenerations[storedId] == _rpc.socketGeneration) {
|
|
return (binding: existing, running: null);
|
|
}
|
|
await _ensureConnected();
|
|
_emitTurnState(HermesDesktopTurnState.synchronizing);
|
|
final result = _object(
|
|
await _rpc.request<Object?>(
|
|
'session.resume',
|
|
params: {
|
|
'session_id': storedId,
|
|
'omit_messages': true,
|
|
..._sessionScope(storedId),
|
|
},
|
|
),
|
|
);
|
|
final runtime = validateHermesOpaqueIdentifier(result['session_id']);
|
|
final stored = validateHermesOpaqueIdentifier(
|
|
result['stored_session_id'] ?? result['session_key'] ?? storedId,
|
|
);
|
|
if (runtime == null || stored == null) {
|
|
throw const FormatException('Hermes returned invalid session IDs.');
|
|
}
|
|
final info = _object(result['info']);
|
|
final running = result['running'] ?? info['running'];
|
|
_recordDesktopContract(info);
|
|
final binding = HermesSessionBinding(storedId: stored, runtimeId: runtime);
|
|
final profile = _sessionProfiles[storedId];
|
|
if (profile != null) _sessionProfiles[stored] = profile;
|
|
_bindings[storedId] = binding;
|
|
_bindings[stored] = binding;
|
|
_bindingSocketGenerations[storedId] = _rpc.socketGeneration;
|
|
_bindingSocketGenerations[stored] = _rpc.socketGeneration;
|
|
_applyAuthoritativeRunning(running, storedId: storedId, runtimeId: runtime);
|
|
if (stored != storedId) {
|
|
_sessionTurnStates[stored] = turnStateFor(storedId);
|
|
_emitSessionTurnState(stored);
|
|
}
|
|
await HermesPendingDecisionStore.rebindSession(
|
|
origin: _origin,
|
|
fromStoredSessionId: storedId,
|
|
toStoredSessionId: stored,
|
|
runtimeId: runtime,
|
|
);
|
|
for (final event in _eventBuffer.pending(runtime)) {
|
|
await _persistPendingDecisionEvent(event);
|
|
}
|
|
await _restorePendingFromResume(result, binding);
|
|
return (binding: binding, running: running is bool ? running : null);
|
|
}
|
|
|
|
Future<void> _restorePendingFromResume(
|
|
Map<String, dynamic> result,
|
|
HermesSessionBinding binding,
|
|
) async {
|
|
final approval = _object(result['pending_approval']);
|
|
final approvalId = validateHermesOpaqueIdentifier(
|
|
approval['request_id'],
|
|
sensitiveValues: config.sensitiveValues,
|
|
);
|
|
if (approvalId != null) {
|
|
final command = approval['command']?.toString() ?? '';
|
|
final description = approval['description']?.toString() ?? '';
|
|
await HermesPendingDecisionStore.upsert(
|
|
origin: _origin,
|
|
storedSessionId: binding.storedId,
|
|
runtimeId: binding.runtimeId,
|
|
requestId: approvalId,
|
|
kind: HermesPendingDesktopDecisionKind.approval,
|
|
prompt: command.isNotEmpty ? command : description,
|
|
choices: _desktopDecisionChoices(approval['choices']),
|
|
sensitiveValues: config.sensitiveValues,
|
|
profile: _sessionProfiles[binding.storedId],
|
|
);
|
|
}
|
|
final clarify = _object(result['pending_clarify']);
|
|
final clarifyId = validateHermesOpaqueIdentifier(
|
|
clarify['request_id'],
|
|
sensitiveValues: config.sensitiveValues,
|
|
);
|
|
if (clarifyId != null) {
|
|
await HermesPendingDecisionStore.upsert(
|
|
origin: _origin,
|
|
storedSessionId: binding.storedId,
|
|
runtimeId: binding.runtimeId,
|
|
requestId: clarifyId,
|
|
kind: HermesPendingDesktopDecisionKind.clarification,
|
|
prompt: clarify['question']?.toString(),
|
|
choices: _desktopDecisionChoices(clarify['choices']),
|
|
multiSelect: clarify['multi_select'] == true,
|
|
sensitiveValues: config.sensitiveValues,
|
|
profile: _sessionProfiles[binding.storedId],
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<String> _runtimeCreateSession({
|
|
String? title,
|
|
CancelToken? cancelToken,
|
|
}) => createDesktopSession(
|
|
title: title,
|
|
options: const HermesDesktopSessionOptions(),
|
|
cancelToken: cancelToken,
|
|
);
|
|
|
|
Future<String> _runtimeCreateDesktopSession({
|
|
String? title,
|
|
required HermesDesktopSessionOptions options,
|
|
CancelToken? cancelToken,
|
|
String? profile,
|
|
bool hidden = false,
|
|
}) async {
|
|
if (cancelToken?.isCancelled == true) throw cancelToken!.cancelError!;
|
|
final selection = hermesDesktopSessionModelSelection(
|
|
options.model,
|
|
options.provider,
|
|
);
|
|
final reasoning = options.reasoningEffort?.trim();
|
|
final normalized = HermesDesktopSessionOptions(
|
|
model: selection.model,
|
|
provider: selection.provider,
|
|
reasoningEffort: reasoning?.isEmpty == true ? null : reasoning,
|
|
fast: options.fast,
|
|
);
|
|
await _ensureConnected();
|
|
final result = _object(
|
|
await _rpc.request<Object?>(
|
|
'session.create',
|
|
params: {
|
|
'source': 'mobile',
|
|
'close_on_disconnect': false,
|
|
'model': ?normalized.model,
|
|
'provider': ?normalized.provider,
|
|
'reasoning_effort': ?normalized.reasoningEffort,
|
|
'fast': ?normalized.fast,
|
|
'profile': ?profile,
|
|
if (hidden) 'hidden': true,
|
|
if (title != null && title.trim().isNotEmpty) 'title': title.trim(),
|
|
},
|
|
),
|
|
);
|
|
if (cancelToken?.isCancelled == true) throw cancelToken!.cancelError!;
|
|
final runtime = validateHermesOpaqueIdentifier(result['session_id']);
|
|
final stored = validateHermesOpaqueIdentifier(result['stored_session_id']);
|
|
if (runtime == null || stored == null) {
|
|
throw const FormatException('Hermes returned invalid session IDs.');
|
|
}
|
|
_bindings[stored] = HermesSessionBinding(
|
|
storedId: stored,
|
|
runtimeId: runtime,
|
|
);
|
|
_bindingSocketGenerations[stored] = _rpc.socketGeneration;
|
|
_appliedSessionOptions[stored] = normalized.fingerprint;
|
|
final info = _object(result['info']);
|
|
_recordDesktopContract(info);
|
|
final running = result['running'] ?? info['running'];
|
|
if (running is bool) {
|
|
_applyAuthoritativeRunning(running, storedId: stored, runtimeId: runtime);
|
|
} else {
|
|
_freshSessionIds.add(stored);
|
|
_applyAuthoritativeRunning(null, storedId: stored, runtimeId: runtime);
|
|
}
|
|
return stored;
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> _runtimeListSessions() async => _objects(
|
|
await _requestJson(
|
|
'GET',
|
|
'/api/sessions',
|
|
query: {'limit': 100, 'offset': 0, 'order': 'recent'},
|
|
),
|
|
'sessions',
|
|
);
|
|
|
|
Future<List<Map<String, dynamic>>> _runtimeGetSessionMessages(
|
|
String id, {
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
// Hermes persists session.create lazily; resume fails until the first prompt.
|
|
if (_freshSessionIds.contains(id) &&
|
|
_bindings.containsKey(id) &&
|
|
_rpc.isReady &&
|
|
_bindingSocketGenerations[id] == _rpc.socketGeneration) {
|
|
return const [];
|
|
}
|
|
// The REST transcript does not need a runtime binding, so a session that
|
|
// the gateway refuses to resume (too large, wrong profile, transient
|
|
// failure) can still be read. Hermes Desktop makes the same split: it
|
|
// loads the transcript independently and treats resume as non-fatal for
|
|
// display. Sending a prompt still resumes and surfaces that error itself.
|
|
HermesSessionBinding? binding;
|
|
try {
|
|
binding = await _resume(id, refresh: true);
|
|
} on HermesDesktopRpcException catch (error) {
|
|
final isBotChat = _sessionProfiles.containsKey(id);
|
|
// Timeouts and disconnects are transport failures, not gateway replies;
|
|
// bot chats read history through the socket and need the runtime ID.
|
|
if (error.deliveryAmbiguous || isBotChat) rethrow;
|
|
DebugLogger.warning(
|
|
'session-resume-failed-loading-rest',
|
|
scope: 'hermes/desktop',
|
|
data: {
|
|
'code': error.code,
|
|
'message': validateHermesBoundedString(
|
|
error.message,
|
|
maxCharacters: 200,
|
|
),
|
|
},
|
|
);
|
|
}
|
|
final runtimeId = binding?.runtimeId;
|
|
final storedId = binding?.storedId ?? id;
|
|
final hadBufferedEvent =
|
|
runtimeId != null && _eventBuffer.take(runtimeId).isNotEmpty;
|
|
final isBotChat = _sessionProfiles.containsKey(storedId);
|
|
|
|
Future<List<Map<String, dynamic>>> load() async {
|
|
if (isBotChat && runtimeId != null) {
|
|
if (cancelToken?.isCancelled == true) throw cancelToken!.cancelError!;
|
|
final messages = _objects(
|
|
await _rpc.request<Object?>(
|
|
'session.history',
|
|
params: {'session_id': runtimeId, ..._sessionScope(storedId)},
|
|
),
|
|
'messages',
|
|
);
|
|
if (cancelToken?.isCancelled == true) throw cancelToken!.cancelError!;
|
|
return messages;
|
|
}
|
|
return loadHermesDesktopTranscriptPages(
|
|
(offset, limit) async => _objects(
|
|
await _requestJson(
|
|
'GET',
|
|
'/api/sessions/${Uri.encodeComponent(storedId)}/messages',
|
|
query: {
|
|
'limit': limit,
|
|
'offset': offset,
|
|
'order': 'oldest',
|
|
'include_compacted': true,
|
|
..._sessionScope(storedId),
|
|
},
|
|
cancelToken: cancelToken,
|
|
),
|
|
'messages',
|
|
),
|
|
);
|
|
}
|
|
|
|
var forceReload = hadBufferedEvent;
|
|
var messages = const <Map<String, dynamic>>[];
|
|
for (var attempt = 0; attempt < 3; attempt++) {
|
|
messages = await load();
|
|
final arrivedDuringLoad =
|
|
runtimeId != null && _eventBuffer.take(runtimeId).isNotEmpty;
|
|
if (!forceReload && !arrivedDuringLoad) break;
|
|
forceReload = false;
|
|
if (attempt == 2) _emitTranscriptChange(id);
|
|
}
|
|
// A failed resume left the connection state at "synchronizing"; the
|
|
// transcript is loaded, and no turn is known to be running.
|
|
if (binding == null) _emitTurnState(HermesDesktopTurnState.idle);
|
|
final usable = preferLastUsableHermesTranscript(
|
|
_lastTranscripts[id] ?? const [],
|
|
messages,
|
|
);
|
|
_lastTranscripts[id] = usable;
|
|
return usable;
|
|
}
|
|
|
|
Future<List<HermesPendingDesktopDecision>> _runtimePendingDecisionsForSession(
|
|
String storedId,
|
|
) async {
|
|
// Restore the session's owning profile BEFORE resuming: after a restart
|
|
// the in-memory map is empty, and an unrestored bot chat would resume and
|
|
// answer under the connection profile.
|
|
await _restorePersistedSessionProfile(storedId);
|
|
final binding = await _resume(storedId);
|
|
await _restorePersistedSessionProfile(binding.storedId);
|
|
return HermesPendingDecisionStore.forSession(
|
|
origin: _origin,
|
|
storedSessionId: binding.storedId,
|
|
);
|
|
}
|
|
|
|
/// Re-seeds `_sessionProfiles` from a durable pending decision, so a bot
|
|
/// chat restored across a restart keeps its profile scope.
|
|
Future<void> _restorePersistedSessionProfile(String storedId) async {
|
|
if (_sessionProfiles.containsKey(storedId)) return;
|
|
for (final decision in await HermesPendingDecisionStore.forSession(
|
|
origin: _origin,
|
|
storedSessionId: storedId,
|
|
)) {
|
|
final profile = decision.profile;
|
|
if (profile != null) {
|
|
_sessionProfiles[storedId] = profile;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _runtimeRenameSession(String id, String title) async {
|
|
final binding = await _resume(id);
|
|
await _rpc.request<Object?>(
|
|
'session.title',
|
|
params: {
|
|
'session_id': binding.runtimeId,
|
|
'title': title,
|
|
..._sessionScope(binding.storedId),
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _runtimeDeleteSession(
|
|
String id, {
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
var binding = _bindings[id];
|
|
if (binding != null) {
|
|
try {
|
|
binding = await _resume(id);
|
|
await _rpc.request<Object?>(
|
|
'session.close',
|
|
params: {
|
|
'session_id': binding.runtimeId,
|
|
..._sessionScope(binding.storedId),
|
|
},
|
|
);
|
|
} catch (error) {
|
|
DebugLogger.warning(
|
|
'session-close-before-delete-failed',
|
|
scope: 'hermes/desktop/sessions',
|
|
data: {'errorType': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
}
|
|
await _requestJson(
|
|
'DELETE',
|
|
'/api/sessions/${Uri.encodeComponent(binding?.storedId ?? id)}',
|
|
query: _sessionScope(binding?.storedId ?? id),
|
|
cancelToken: cancelToken,
|
|
);
|
|
if (binding != null) {
|
|
_bindings.removeWhere((_, candidate) => identical(candidate, binding));
|
|
_freshSessionIds.remove(binding.storedId);
|
|
_bindingSocketGenerations.removeWhere(
|
|
(alias, _) => !_bindings.containsKey(alias),
|
|
);
|
|
_lastTranscripts.remove(binding.storedId);
|
|
_sessionProfiles.remove(binding.storedId);
|
|
}
|
|
_lastTranscripts.remove(id);
|
|
_sessionProfiles.remove(id);
|
|
await HermesPendingDecisionStore.clearSession(
|
|
origin: _origin,
|
|
storedSessionId: binding?.storedId ?? id,
|
|
);
|
|
}
|
|
|
|
bool _runtimeSessionIdsReferToSameBinding(String left, String right) =>
|
|
left == right ||
|
|
(_bindings[left] != null && identical(_bindings[left], _bindings[right]));
|
|
|
|
Future<String> _runtimeForkSession(String id) async {
|
|
final binding = await _resume(id);
|
|
final result = _object(
|
|
await _rpc.request<Object?>(
|
|
'session.branch',
|
|
params: {
|
|
'session_id': binding.runtimeId,
|
|
..._sessionScope(binding.storedId),
|
|
},
|
|
),
|
|
);
|
|
final runtime = validateHermesOpaqueIdentifier(result['session_id']);
|
|
final stored = validateHermesOpaqueIdentifier(result['stored_session_id']);
|
|
if (runtime == null || stored == null) {
|
|
throw const FormatException('Hermes returned invalid branch IDs.');
|
|
}
|
|
_bindings[stored] = HermesSessionBinding(
|
|
storedId: stored,
|
|
runtimeId: runtime,
|
|
);
|
|
_bindingSocketGenerations[stored] = _rpc.socketGeneration;
|
|
// A branch of a bot's chat lives in that bot's profile too; without this
|
|
// every later call for the fork would target the configured profile.
|
|
final profile = _sessionProfiles[binding.storedId];
|
|
if (profile != null) _sessionProfiles[stored] = profile;
|
|
return stored;
|
|
}
|
|
}
|