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.
168 lines
4.8 KiB
Dart
168 lines
4.8 KiB
Dart
/// Default structural ceilings for JSON received from a Hermes server.
|
|
const int kMaxHermesJsonDepth = 128;
|
|
const int kMaxHermesJsonNodes = 100000;
|
|
const int kMaxHermesJsonTokens = 400000;
|
|
|
|
enum HermesJsonLimit { depth, nodes, tokens }
|
|
|
|
/// A structural JSON limit exceeded before the source reached `jsonDecode`.
|
|
final class HermesJsonGuardException implements FormatException {
|
|
const HermesJsonGuardException(this.limit);
|
|
|
|
final HermesJsonLimit limit;
|
|
|
|
@override
|
|
String get message => switch (limit) {
|
|
HermesJsonLimit.depth => 'Hermes JSON exceeds the nesting limit.',
|
|
HermesJsonLimit.nodes => 'Hermes JSON exceeds the value limit.',
|
|
HermesJsonLimit.tokens => 'Hermes JSON exceeds the token limit.',
|
|
};
|
|
|
|
@override
|
|
int? get offset => null;
|
|
|
|
@override
|
|
Object? get source => null;
|
|
|
|
@override
|
|
String toString() => 'FormatException: $message';
|
|
}
|
|
|
|
/// A Hermes response or frame exceeded Conduit's transport size caps before
|
|
/// it was decoded. Callers can page smaller or tell the user the conversation
|
|
/// is too large; the payload itself is never retained.
|
|
final class HermesResponseTooLargeException implements FormatException {
|
|
const HermesResponseTooLargeException([
|
|
this.message = 'Hermes response is too large.',
|
|
]);
|
|
|
|
@override
|
|
final String message;
|
|
|
|
@override
|
|
int? get offset => null;
|
|
|
|
@override
|
|
Object? get source => null;
|
|
|
|
@override
|
|
String toString() => 'HermesResponseTooLargeException: $message';
|
|
}
|
|
|
|
/// Scans untrusted JSON without constructing its decoded object graph.
|
|
///
|
|
/// Strings are scanned in place so structural characters inside them do not
|
|
/// affect nesting. Allocation-bearing value tokens (including object keys)
|
|
/// count toward [maxNodes], while punctuation and values both count toward
|
|
/// [maxTokens]. JSON syntax remains the responsibility of `jsonDecode`; this
|
|
/// preflight only guarantees that malformed or valid input has finite lexical
|
|
/// and structural complexity before decoding begins.
|
|
void validateHermesJsonSource(
|
|
String source, {
|
|
int maxDepth = kMaxHermesJsonDepth,
|
|
int maxNodes = kMaxHermesJsonNodes,
|
|
int maxTokens = kMaxHermesJsonTokens,
|
|
}) {
|
|
if (maxDepth <= 0) throw RangeError.value(maxDepth, 'maxDepth');
|
|
if (maxNodes <= 0) throw RangeError.value(maxNodes, 'maxNodes');
|
|
if (maxTokens <= 0) throw RangeError.value(maxTokens, 'maxTokens');
|
|
|
|
var depth = 0;
|
|
var nodes = 0;
|
|
var tokens = 0;
|
|
var index = 0;
|
|
|
|
void addToken({required bool allocationBearing}) {
|
|
tokens++;
|
|
if (tokens > maxTokens) {
|
|
throw const HermesJsonGuardException(HermesJsonLimit.tokens);
|
|
}
|
|
if (!allocationBearing) return;
|
|
nodes++;
|
|
if (nodes > maxNodes) {
|
|
throw const HermesJsonGuardException(HermesJsonLimit.nodes);
|
|
}
|
|
}
|
|
|
|
while (index < source.length) {
|
|
final codeUnit = source.codeUnitAt(index);
|
|
if (_isJsonWhitespace(codeUnit)) {
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (codeUnit == _quote) {
|
|
addToken(allocationBearing: true);
|
|
index++;
|
|
var escaped = false;
|
|
while (index < source.length) {
|
|
final stringCodeUnit = source.codeUnitAt(index++);
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (stringCodeUnit == _backslash) {
|
|
escaped = true;
|
|
} else if (stringCodeUnit == _quote) {
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (codeUnit == _openBrace || codeUnit == _openBracket) {
|
|
addToken(allocationBearing: true);
|
|
depth++;
|
|
if (depth > maxDepth) {
|
|
throw const HermesJsonGuardException(HermesJsonLimit.depth);
|
|
}
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (codeUnit == _closeBrace || codeUnit == _closeBracket) {
|
|
addToken(allocationBearing: false);
|
|
if (depth > 0) depth--;
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (codeUnit == _colon || codeUnit == _comma) {
|
|
addToken(allocationBearing: false);
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
// Numbers, true, false, null, and malformed bare lexemes are each one
|
|
// allocation-bearing token. `jsonDecode` validates their exact spelling.
|
|
addToken(allocationBearing: true);
|
|
index++;
|
|
while (index < source.length) {
|
|
final next = source.codeUnitAt(index);
|
|
if (_isJsonWhitespace(next) || _isJsonDelimiter(next)) break;
|
|
index++;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isJsonWhitespace(int codeUnit) =>
|
|
codeUnit == 0x20 ||
|
|
codeUnit == 0x09 ||
|
|
codeUnit == 0x0A ||
|
|
codeUnit == 0x0D;
|
|
|
|
bool _isJsonDelimiter(int codeUnit) =>
|
|
codeUnit == _openBrace ||
|
|
codeUnit == _closeBrace ||
|
|
codeUnit == _openBracket ||
|
|
codeUnit == _closeBracket ||
|
|
codeUnit == _colon ||
|
|
codeUnit == _comma ||
|
|
codeUnit == _quote;
|
|
|
|
const int _quote = 0x22;
|
|
const int _comma = 0x2C;
|
|
const int _colon = 0x3A;
|
|
const int _openBracket = 0x5B;
|
|
const int _backslash = 0x5C;
|
|
const int _closeBracket = 0x5D;
|
|
const int _openBrace = 0x7B;
|
|
const int _closeBrace = 0x7D;
|