mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-19 01:37:03 +00:00
Fix ten reported issues and vendor Hermes Agent as an API reference (#725)
Some checks are pending
L10n / l10n (push) Waiting to run
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.
This commit is contained in:
parent
6c6e02f824
commit
4e73c9a0ea
65 changed files with 3132 additions and 158 deletions
4
.gitmodules
vendored
4
.gitmodules
vendored
|
|
@ -7,3 +7,7 @@
|
|||
[submodule "third_party/katex"]
|
||||
path = third_party/katex
|
||||
url = https://github.com/orestesgaolin/katex.git
|
||||
[submodule "hermes-src"]
|
||||
path = hermes-src
|
||||
url = https://github.com/NousResearch/hermes-agent
|
||||
branch = main
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ git clone --recursive https://github.com/cogwheel0/conduit.git
|
|||
cd conduit
|
||||
```
|
||||
|
||||
`--recursive` matters. Conduit vendors three submodules:
|
||||
`--recursive` matters. Conduit vendors four submodules:
|
||||
|
||||
- `third_party/mermaid`: the native Mermaid renderer packages
|
||||
(`mermaid_core`, `mermaid_flutter`), referenced by path from `pubspec.yaml`.
|
||||
|
|
@ -41,6 +41,10 @@ cd conduit
|
|||
- `third_party/katex`: KaTeX assets for math rendering.
|
||||
- `openwebui-src`: a vendored Open WebUI checkout used **only** as an API
|
||||
reference. It is not built or shipped.
|
||||
- `hermes-src`: a vendored Hermes Agent checkout (NousResearch/hermes-agent)
|
||||
used **only** as the reference for the Hermes gateway RPC and REST contracts
|
||||
(`tui_gateway/`, `gateway/platforms/api_server.py`). It is not built or
|
||||
shipped.
|
||||
|
||||
For an existing clone:
|
||||
|
||||
|
|
|
|||
1
hermes-src
Submodule
1
hermes-src
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 939e45c91d751fadd94dcd1b873ac3cb44846213
|
||||
|
|
@ -28,6 +28,11 @@ final class PreferenceKeys {
|
|||
/// It prevents bearer/credential restoration and proxy-cookie attachment
|
||||
/// after a process death or incomplete secure-storage cleanup.
|
||||
static const String incompleteLogoutFence = 'incomplete_logout_fence_v1';
|
||||
|
||||
/// Set when a user-requested full app-data clear failed part-way. Keeps
|
||||
/// surviving Direct profiles hidden and unusable across restarts until the
|
||||
/// logout fence is cleared.
|
||||
static const String incompleteAppDataClear = 'incomplete_app_data_clear_v1';
|
||||
static const String appIntentInvocationLedger =
|
||||
'app_intent_invocation_ledger_v1';
|
||||
static const String themeMode = 'theme_mode';
|
||||
|
|
@ -39,6 +44,8 @@ final class PreferenceKeys {
|
|||
'release_notes_existing_install_at_bootstrap_v1';
|
||||
static const String releaseNotesBannerPreviousVersion =
|
||||
'release_notes_banner_previous_version_v1';
|
||||
static const String serverVersionWarningDismissed =
|
||||
'server_version_warning_dismissed_v1';
|
||||
static const String ttsVoice = 'tts_voice';
|
||||
static const String ttsVoiceName = 'tts_voice_name';
|
||||
static const String ttsSpeechRate = 'tts_speech_rate';
|
||||
|
|
|
|||
|
|
@ -169,6 +169,10 @@ final class SignOutCoordinator {
|
|||
directMcpServers.blockMutationsForAppDataClear(),
|
||||
hermesConfig.blockMutationsForAppDataClear(),
|
||||
]);
|
||||
// Armed before anything is wiped: a process death mid-clear must not
|
||||
// bring surviving Direct profiles back on restart. Failing to arm it
|
||||
// aborts the clear through the catch below.
|
||||
await armIncompleteAppDataClearMarker();
|
||||
_ref.invalidate(directProviderAdapterRegistryProvider);
|
||||
_ref.invalidate(directModelDiscoveryProvider);
|
||||
_ref.invalidate(directHttpClientPoolProvider);
|
||||
|
|
@ -203,6 +207,7 @@ final class SignOutCoordinator {
|
|||
// admission barrier and may still lose auth ownership.
|
||||
await _ref.read(directLocalDatabasePurgeProvider)();
|
||||
directLocalPurgeCompleted = true;
|
||||
await disarmIncompleteAppDataClearMarker();
|
||||
_resetProvidersAfterFullAppDataClear(_ref);
|
||||
case FullAppDataClearOutcome.incomplete:
|
||||
directRuns.commitAppDataClear();
|
||||
|
|
@ -211,10 +216,13 @@ final class SignOutCoordinator {
|
|||
directMcpServers.blockMutationsForAppDataClear(),
|
||||
hermesConfig.blockMutationsForAppDataClear(),
|
||||
]);
|
||||
directProfiles.revokeRuntimeAfterIncompleteAppDataClear();
|
||||
directMcpServers.revokeRuntimeAfterIncompleteAppDataClear();
|
||||
hermesConfig.revokeRuntimeAfterIncompleteAppDataClear();
|
||||
// Awaited last: it persists the restart marker that keeps surviving
|
||||
// Direct profiles hidden, and must be durable before returning.
|
||||
await directProfiles.revokeRuntimeAfterIncompleteAppDataClear();
|
||||
case FullAppDataClearOutcome.ownershipYielded:
|
||||
await disarmIncompleteAppDataClearMarker();
|
||||
resumeGlobalAdmission();
|
||||
directProfiles.resumeMutationsAfterAppDataClearAbort();
|
||||
directMcpServers.resumeMutationsAfterAppDataClearAbort();
|
||||
|
|
|
|||
|
|
@ -7333,6 +7333,7 @@ class ApiService {
|
|||
List<Map<String, dynamic>>? toolServers,
|
||||
Map<String, dynamic>? backgroundTasks,
|
||||
Map<String, dynamic>? userSettings,
|
||||
String? reasoningEffort,
|
||||
String? parentId,
|
||||
Map<String, dynamic>? userMessage,
|
||||
Map<String, dynamic>? variables,
|
||||
|
|
@ -7474,6 +7475,15 @@ class ApiService {
|
|||
// Non-critical: proceed without user params
|
||||
}
|
||||
|
||||
// The user's per-model pick is the chat-level `params` the web client
|
||||
// spreads after `$settings.params`. Sent explicitly, it reaches the
|
||||
// server as a top-level form field, so the model's configured
|
||||
// `reasoning_effort` default is skipped (apply_model_params_to_body only
|
||||
// fills keys absent from the body).
|
||||
if (reasoningEffort != null) {
|
||||
params['reasoning_effort'] = reasoningEffort;
|
||||
}
|
||||
|
||||
final modelInfo = modelItem?['info'];
|
||||
final openAiModel = modelItem?['openai'];
|
||||
final effort = params['reasoning_effort'];
|
||||
|
|
@ -7656,6 +7666,7 @@ class ApiService {
|
|||
Map<String, dynamic>? backgroundTasks,
|
||||
String? responseMessageId,
|
||||
Map<String, dynamic>? userSettings,
|
||||
String? reasoningEffort,
|
||||
String? parentId,
|
||||
Map<String, dynamic>? userMessage,
|
||||
Map<String, dynamic>? variables,
|
||||
|
|
@ -7728,6 +7739,7 @@ class ApiService {
|
|||
toolServers: toolServers,
|
||||
backgroundTasks: backgroundTasks,
|
||||
userSettings: userSettings,
|
||||
reasoningEffort: reasoningEffort,
|
||||
parentId: parentId,
|
||||
userMessage: userMessage,
|
||||
variables: variables,
|
||||
|
|
@ -8197,6 +8209,7 @@ class ApiService {
|
|||
List<Map<String, dynamic>>? toolServers,
|
||||
Map<String, dynamic>? backgroundTasks,
|
||||
Map<String, dynamic>? userSettings,
|
||||
String? reasoningEffort,
|
||||
String? parentId,
|
||||
Map<String, dynamic>? userMessage,
|
||||
Map<String, dynamic>? variables,
|
||||
|
|
@ -8218,6 +8231,7 @@ class ApiService {
|
|||
toolServers: toolServers,
|
||||
backgroundTasks: backgroundTasks,
|
||||
userSettings: userSettings,
|
||||
reasoningEffort: reasoningEffort,
|
||||
parentId: parentId,
|
||||
userMessage: userMessage,
|
||||
variables: variables,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ nativeHydratedServerReasoningEffort({
|
|||
required Model model,
|
||||
required ServerModelReasoningEffort detail,
|
||||
String? personalizationEffort,
|
||||
String? localEffort,
|
||||
}) {
|
||||
final modelEffort = detail.value ?? modelConfiguredReasoningEffort(model);
|
||||
final policy = model.supportsReasoningEffort || modelEffort != null
|
||||
|
|
@ -83,7 +84,7 @@ nativeHydratedServerReasoningEffort({
|
|||
policy: policy,
|
||||
value:
|
||||
policy.effectiveConfiguredEffort(
|
||||
modelEffort ?? personalizationEffort,
|
||||
localEffort ?? modelEffort ?? personalizationEffort,
|
||||
) ??
|
||||
kAutomaticReasoningEffort,
|
||||
);
|
||||
|
|
@ -303,6 +304,10 @@ class NativeSheetHydrationService {
|
|||
.asData
|
||||
?.value
|
||||
.reasoningEffort,
|
||||
localEffort: localReasoningEffortForModel(
|
||||
_ref.read,
|
||||
lateEffortModel,
|
||||
),
|
||||
);
|
||||
await bridge.updateModelSelectorReasoningEffort(
|
||||
presentationId: presentationId,
|
||||
|
|
|
|||
|
|
@ -420,7 +420,10 @@ class _AssistantServerPatch {
|
|||
Future<void> _handleReconnectRecovery({
|
||||
required bool Function() hasFinished,
|
||||
required List<ChatMessage> Function() getMessages,
|
||||
required Future<_ServerMessageSnapshot?> Function() pollServerForMessage,
|
||||
required Future<_ServerMessageSnapshot?> Function({
|
||||
bool inferDoneFromMissingStreaming,
|
||||
})
|
||||
pollServerForMessage,
|
||||
required bool Function(
|
||||
String,
|
||||
List<String>, {
|
||||
|
|
@ -442,7 +445,13 @@ Future<void> _handleReconnectRecovery({
|
|||
return;
|
||||
}
|
||||
|
||||
final result = await pollServerForMessage();
|
||||
// Open WebUI persists the in-progress assistant with `done: false` and no
|
||||
// `isStreaming` key, so a missing flag says nothing about completion.
|
||||
// Inferring "done" from it here finished a live stream the moment the app
|
||||
// came back to the foreground; only an explicit done/error is terminal.
|
||||
final result = await pollServerForMessage(
|
||||
inferDoneFromMissingStreaming: false,
|
||||
);
|
||||
if (hasFinished()) return;
|
||||
|
||||
if (result != null) {
|
||||
|
|
@ -1430,32 +1439,32 @@ ActiveChatStream attachUnifiedChunkedStreaming({
|
|||
}) {
|
||||
final eventType = event['type']?.toString() ?? '';
|
||||
if (!openWebUIResponseStreamEventTouchesOutput(eventType)) return;
|
||||
if (eventType == 'response.failed' || eventType == 'response.incomplete') {
|
||||
// Terminal failure or cut-off: keep whatever output landed, then
|
||||
// surface the state so a trailing [DONE] cannot finish the turn as a
|
||||
// clean success.
|
||||
if (eventType == 'response.failed') {
|
||||
// Terminal failure: keep whatever output landed, then surface the state
|
||||
// so a trailing [DONE] cannot finish the turn as a clean success.
|
||||
final response = event['response'];
|
||||
final String? message;
|
||||
if (eventType == 'response.failed') {
|
||||
final error = response is Map ? response['error'] : null;
|
||||
final text = error is Map
|
||||
? error['message']?.toString()
|
||||
: error?.toString();
|
||||
message = text == null || text.trim().isEmpty
|
||||
? 'The response failed.'
|
||||
: text;
|
||||
} else {
|
||||
final details = response is Map ? response['incomplete_details'] : null;
|
||||
final reason = details is Map ? details['reason']?.toString() : null;
|
||||
message = reason == null || reason.trim().isEmpty
|
||||
? 'The response stopped before it was complete.'
|
||||
: 'The response stopped before it was complete ($reason).';
|
||||
}
|
||||
final error = response is Map ? response['error'] : null;
|
||||
final text = error is Map
|
||||
? error['message']?.toString()
|
||||
: error?.toString();
|
||||
final message = text == null || text.trim().isEmpty
|
||||
? 'The response failed.'
|
||||
: text;
|
||||
applyAssistantServerPatch(
|
||||
targetId: targetId,
|
||||
buildPatch: (_) =>
|
||||
_AssistantServerPatch(error: ChatMessageError(content: message)),
|
||||
);
|
||||
} else if (eventType == 'response.incomplete') {
|
||||
// Upstream contract (middleware.handle_responses_streaming_event): a
|
||||
// cut-off response is not an error. The server keeps the output, runs
|
||||
// the turn to its normal `done`, and the web client shows no banner.
|
||||
// Raising an error here painted "stopped before it was complete" under
|
||||
// the answer until that `done` cleared it again.
|
||||
DebugLogger.log(
|
||||
'response.incomplete: keeping output without an error',
|
||||
scope: 'streaming/helper',
|
||||
);
|
||||
}
|
||||
latestResponseOutputItems = applyOpenWebUIResponseStreamEvent(
|
||||
latestResponseOutputItems,
|
||||
|
|
|
|||
67
lib/core/utils/persisted_message_content.dart
Normal file
67
lib/core/utils/persisted_message_content.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import '../models/chat_message.dart';
|
||||
import '../services/direct_replay_output.dart';
|
||||
import 'semantic_details.dart';
|
||||
|
||||
/// The `content` string Conduit persists for [message] in Open WebUI chat
|
||||
/// payloads and the local message rows that feed them.
|
||||
///
|
||||
/// Open WebUI's own web client stores `content = getOutputText(output)` (the
|
||||
/// joined text of `type == 'message'` output items) and rebuilds the
|
||||
/// reasoning / tool-call `<details>` presentation from `output` on load
|
||||
/// (`Chat.svelte`, `structuredOutput.ts`). Conduit renders those wrappers
|
||||
/// into `ChatMessage.content` locally, so persisting it verbatim leaks the
|
||||
/// markup into shares, exports, and anything else that reads `content`
|
||||
/// (issue #703). `conversation_parsing.dart` re-synthesizes the wrappers
|
||||
/// from `output` on load, so a clean `content` plus an intact `output`
|
||||
/// round-trips for display.
|
||||
///
|
||||
/// The content is returned untouched when it is the only representation the
|
||||
/// message has:
|
||||
///
|
||||
/// * `output` is null or empty (older servers, Hermes, legacy shapes);
|
||||
/// * `output` is Conduit's direct replay mirror, because on load the mirror
|
||||
/// short-circuits the structured-output re-synthesis and the rendered
|
||||
/// `content` is what the UI shows;
|
||||
/// * `output` yields no message text and the content has no prose outside
|
||||
/// its `<details>` wrappers.
|
||||
String persistedMessageContent(ChatMessage message) {
|
||||
final content = message.content;
|
||||
if (message.role != 'assistant') return content;
|
||||
final output = message.output;
|
||||
if (output == null || output.isEmpty) return content;
|
||||
if (parseConduitDirectReplayOutput(output) != null) return content;
|
||||
|
||||
final outputText = outputItemsMessageText(output);
|
||||
if (outputText.trim().isNotEmpty) return outputText;
|
||||
|
||||
final stripped = stripRenderedSemanticDetails(content);
|
||||
if (stripped.trim().isNotEmpty) return stripped;
|
||||
return content;
|
||||
}
|
||||
|
||||
/// Mirrors Open WebUI's `getOutputText`: the text of every `type == 'message'`
|
||||
/// item (its `content[].text` parts concatenated), skipping blank items and
|
||||
/// joining the rest with a single newline.
|
||||
String outputItemsMessageText(List<dynamic> output) {
|
||||
final texts = <String>[];
|
||||
for (final item in output) {
|
||||
if (item is! Map || item['type'] != 'message') continue;
|
||||
final text = _messageItemText(item);
|
||||
if (text.trim().isEmpty) continue;
|
||||
texts.add(text);
|
||||
}
|
||||
return texts.join('\n');
|
||||
}
|
||||
|
||||
String _messageItemText(Map<dynamic, dynamic> item) {
|
||||
final content = item['content'];
|
||||
if (content is! List) return '';
|
||||
final buffer = StringBuffer();
|
||||
for (final part in content) {
|
||||
if (part is! Map) continue;
|
||||
final text = part['text'];
|
||||
if (text == null) continue;
|
||||
buffer.write(text is String ? text : text.toString());
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ import '../../../core/utils/debug_logger.dart';
|
|||
import '../../../core/utils/json_normalization.dart';
|
||||
import '../../../core/utils/message_tree_utils.dart' as message_tree;
|
||||
import '../../../core/utils/openwebui_message_payload.dart';
|
||||
import '../../../core/utils/persisted_message_content.dart';
|
||||
import '../../../core/utils/semantic_details.dart';
|
||||
import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../utils/follow_ups_socket_event.dart';
|
||||
|
|
@ -6575,6 +6576,11 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>>
|
|||
/// server's merge replaces each message object wholesale, so any field
|
||||
/// omitted here (`output`, `sources`, `usage`, …) would be wiped from the
|
||||
/// server copy on the next push.
|
||||
///
|
||||
/// `content` is stored the way the Open WebUI web client stores it: the plain
|
||||
/// output text when `output` carries the turn (see
|
||||
/// [persistedMessageContent]); the rendered `<details>` presentation is
|
||||
/// re-synthesized from `output` on load.
|
||||
MessageRowData localEchoRowForMessage(String chatId, ChatMessage message) {
|
||||
final timestamp = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||
final resolvedParentId = message_tree.chatMessageParentId(message);
|
||||
|
|
@ -6587,7 +6593,7 @@ MessageRowData localEchoRowForMessage(String chatId, ChatMessage message) {
|
|||
chatId: chatId,
|
||||
parentId: resolvedParentId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
content: persistedMessageContent(message),
|
||||
model: message.model,
|
||||
createdAt: timestamp,
|
||||
// Recomputed by upsertLocalEcho for new rows.
|
||||
|
|
@ -6597,7 +6603,7 @@ MessageRowData localEchoRowForMessage(String chatId, ChatMessage message) {
|
|||
'parentId': resolvedParentId,
|
||||
'childrenIds': childrenIds,
|
||||
'role': message.role,
|
||||
'content': message.content,
|
||||
'content': persistedMessageContent(message),
|
||||
'timestamp': timestamp,
|
||||
'isStreaming': message.isStreaming,
|
||||
if (message.role == 'assistant' && !message.isStreaming) 'done': true,
|
||||
|
|
@ -9916,6 +9922,7 @@ Future<void> regenerateMessage(
|
|||
backgroundTasks: bgTasks,
|
||||
responseMessageId: assistantMessageId,
|
||||
userSettings: userSettingsData,
|
||||
reasoningEffort: reasoningEffortForModel(ref.read, selectedModel),
|
||||
parentId: parentMsgMap?['parentId']?.toString(),
|
||||
userMessage: parentMsgMap,
|
||||
variables: promptVars2,
|
||||
|
|
@ -10403,6 +10410,10 @@ Future<void> runQueuedCompletion(
|
|||
backgroundTasks: bgTasks,
|
||||
responseMessageId: assistantMessageId,
|
||||
userSettings: userSettingsData,
|
||||
reasoningEffort:
|
||||
selectedModel != null && selectedModel.id == effectiveModelId
|
||||
? reasoningEffortForModel(ref.read, selectedModel)
|
||||
: null,
|
||||
parentId: parentMsgMap?['parentId']?.toString(),
|
||||
userMessage: parentMsgMap,
|
||||
variables: promptVars2,
|
||||
|
|
@ -10639,6 +10650,10 @@ Future<void> runHeadlessCompletion(
|
|||
backgroundTasks: bgTasks,
|
||||
responseMessageId: assistantMessageId,
|
||||
userSettings: userSettingsData,
|
||||
reasoningEffort:
|
||||
selectedModel != null && selectedModel.id == effectiveModelId
|
||||
? reasoningEffortForModel(ref.read, selectedModel)
|
||||
: null,
|
||||
parentId: parentMsgMap?['parentId']?.toString(),
|
||||
userMessage: parentMsgMap,
|
||||
variables: promptVars,
|
||||
|
|
@ -15132,7 +15147,7 @@ Map<String, dynamic> _directPersistedMessagePayload(
|
|||
'parentId': parentId,
|
||||
'childrenIds': childrenIds,
|
||||
'role': message.role,
|
||||
'content': message.content,
|
||||
'content': persistedMessageContent(message),
|
||||
'isStreaming': message.isStreaming,
|
||||
if (message.role == 'assistant' && !message.isStreaming) 'done': true,
|
||||
if (message.model != null) 'model': message.model,
|
||||
|
|
@ -15198,7 +15213,7 @@ MessageRowData _directMessageRow({
|
|||
chatId: chatId,
|
||||
parentId: parentId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
content: persistedMessageContent(message),
|
||||
model: message.model,
|
||||
createdAt: message.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
orderIndex: orderIndex,
|
||||
|
|
@ -18309,6 +18324,7 @@ Future<void> _sendMessageInternal(
|
|||
backgroundTasks: bgTasks,
|
||||
responseMessageId: assistantMessageId,
|
||||
userSettings: userSettingsData,
|
||||
reasoningEffort: reasoningEffortForModel(ref.read, selectedModel),
|
||||
parentId: userMessageMap?['parentId']?.toString(),
|
||||
userMessage: userMessageMap,
|
||||
variables: promptVariables,
|
||||
|
|
|
|||
|
|
@ -193,15 +193,45 @@ class LocalReasoningEfforts extends _$LocalReasoningEfforts {
|
|||
}
|
||||
}
|
||||
|
||||
String? _localEffortKey(Ref ref) {
|
||||
final model = ref.watch(selectedModelProvider);
|
||||
if (model == null) return null;
|
||||
String? _openWebUiEffortKey(ReasoningEffortReader read, Model model) {
|
||||
final serverId = read(apiServiceProvider)?.serverConfig.id;
|
||||
if (serverId == null || serverId.isEmpty) return null;
|
||||
return 'openwebui:$serverId:${model.id}';
|
||||
}
|
||||
|
||||
/// Storage key in [LocalReasoningEfforts] for the user's own pick on [model].
|
||||
///
|
||||
/// Open WebUI server models are namespaced by the active server id so the
|
||||
/// same model id on two servers never shares a preference.
|
||||
String? localReasoningEffortKeyForModel(
|
||||
ReasoningEffortReader read,
|
||||
Model model,
|
||||
) {
|
||||
if (isHermesModel(model)) return 'hermes:${model.id}';
|
||||
final binding = ref.watch(directModelRegistryProvider).resolve(model);
|
||||
final binding = read(directModelRegistryProvider).resolve(model);
|
||||
if (binding != null) {
|
||||
return 'direct:${binding.profileId}:${binding.remoteModelId}';
|
||||
}
|
||||
return null;
|
||||
return _openWebUiEffortKey(read, model);
|
||||
}
|
||||
|
||||
/// The raw per-model pick stored locally for [model], or null when the user
|
||||
/// has not chosen one. Open WebUI models may store an explicit
|
||||
/// [kAutomaticReasoningEffort] to mean "let the server decide".
|
||||
String? localReasoningEffortForModel(ReasoningEffortReader read, Model model) {
|
||||
final key = localReasoningEffortKeyForModel(read, model);
|
||||
if (key == null) return null;
|
||||
return read(localReasoningEffortsProvider)[key];
|
||||
}
|
||||
|
||||
bool _isOpenWebUiServerModel(ReasoningEffortReader read, Model model) =>
|
||||
!isHermesModel(model) &&
|
||||
read(directModelRegistryProvider).resolve(model) == null;
|
||||
|
||||
String? _localEffortKey(Ref ref) {
|
||||
final model = ref.watch(selectedModelProvider);
|
||||
if (model == null) return null;
|
||||
return localReasoningEffortKeyForModel(ref.watch, model);
|
||||
}
|
||||
|
||||
final configuredReasoningEffortProvider = Provider<String?>((ref) {
|
||||
|
|
@ -220,12 +250,16 @@ final configuredReasoningEffortProvider = Provider<String?>((ref) {
|
|||
}
|
||||
|
||||
final localKey = _localEffortKey(ref);
|
||||
if (localKey != null) {
|
||||
final localEffort = localKey == null
|
||||
? null
|
||||
: ref.watch(localReasoningEffortsProvider)[localKey];
|
||||
// A user's own pick always wins. Hermes and Direct models have no
|
||||
// server-side default, so they resolve entirely from the local record.
|
||||
if (localEffort != null ||
|
||||
(localKey != null && !_isOpenWebUiServerModel(ref.watch, model))) {
|
||||
return ref
|
||||
.watch(reasoningEffortPolicyProvider)
|
||||
.effectiveConfiguredEffort(
|
||||
ref.watch(localReasoningEffortsProvider)[localKey],
|
||||
);
|
||||
.effectiveConfiguredEffort(localEffort);
|
||||
}
|
||||
|
||||
String? detailedModelEffort;
|
||||
|
|
@ -299,6 +333,14 @@ String reasoningEffortForModel(ReasoningEffortReader read, Model? model) {
|
|||
return read(localReasoningEffortsProvider)['hermes:${model.id}'] ??
|
||||
kAutomaticReasoningEffort;
|
||||
}
|
||||
final localEffort = localReasoningEffortForModel(read, model);
|
||||
if (localEffort != null) {
|
||||
return reasoningEffortPolicyForModel(
|
||||
read,
|
||||
model,
|
||||
).effectiveConfiguredEffort(localEffort) ??
|
||||
kAutomaticReasoningEffort;
|
||||
}
|
||||
String? detailedModelEffort;
|
||||
if (_isOpenWebUiWorkspaceModel(model)) {
|
||||
final detailedEffort = read(serverModelReasoningEffortProvider(model));
|
||||
|
|
@ -451,9 +493,15 @@ Future<void> setReasoningEffortForModel(
|
|||
.set('hermes:${model.id}', configured);
|
||||
return;
|
||||
}
|
||||
if (read(apiServiceProvider) != null) {
|
||||
await read(personalizationSettingsProvider.notifier)
|
||||
.setReasoningEffort(configured);
|
||||
// Open WebUI server models: keep the pick per model and per server, like the
|
||||
// web client's chat-level Controls, and send it explicitly with each
|
||||
// completion so it wins over the model's configured default. An explicit
|
||||
// "automatic" is stored too, so the pick can override a model default back
|
||||
// to "let the server decide".
|
||||
final serverKey = _openWebUiEffortKey(read, model);
|
||||
if (serverKey != null) {
|
||||
await read(localReasoningEffortsProvider.notifier)
|
||||
.set(serverKey, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,11 @@ class VoiceInputService {
|
|||
? AudioManagerMode.modeInCommunication
|
||||
: AudioManagerMode.modeNormal,
|
||||
speakerphone: false,
|
||||
manageBluetooth: true,
|
||||
// During voice calls the audio session coordinator owns SCO and
|
||||
// communication-device selection. Letting the record plugin manage
|
||||
// Bluetooth makes every recorder stop clear the communication device
|
||||
// (issue #716: the loudspeaker route is wiped right before TTS speaks).
|
||||
manageBluetooth: !voiceCallSession,
|
||||
useLegacy: false,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,13 @@ class ChatVoiceAudioSessionCoordinator {
|
|||
@visibleForTesting
|
||||
bool debugRefuseRouteChanges = false;
|
||||
|
||||
/// Runs the Android route calls on a test host, where the audio manager
|
||||
/// channel is mocked. The real platform check still wins on a device.
|
||||
@visibleForTesting
|
||||
bool debugTreatAsAndroid = false;
|
||||
|
||||
bool get _isAndroid => Platform.isAndroid || debugTreatAsAndroid;
|
||||
|
||||
/// The phases teardown has run for, in order. A test host has no audio route
|
||||
/// to watch come back, so this is the only trace teardown leaves behind.
|
||||
/// Only filled in when asserts are on.
|
||||
|
|
@ -398,7 +405,7 @@ class ChatVoiceAudioSessionCoordinator {
|
|||
/// it has. Call this before the first pass of a call, not after a manual
|
||||
/// toggle, or it would overwrite the user's choice.
|
||||
Future<void> applyDefaultSpeakerphoneRoute() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
if (!_isAndroid && !Platform.isIOS) {
|
||||
return;
|
||||
}
|
||||
if (_routeChangesStopped) {
|
||||
|
|
@ -659,7 +666,7 @@ class ChatVoiceAudioSessionCoordinator {
|
|||
// be set before the platform calls and put back if they refuse.
|
||||
_speakerphoneEnabled = enabled;
|
||||
var applied = true;
|
||||
if (Platform.isAndroid) {
|
||||
if (_isAndroid) {
|
||||
final manager = _androidAudioManager ??= AndroidAudioManager();
|
||||
// Release the currently selected communication device before re-routing:
|
||||
// setCommunicationDevice replaces the route, but leaving SCO running
|
||||
|
|
@ -694,7 +701,7 @@ class ChatVoiceAudioSessionCoordinator {
|
|||
/// Points the Android call at the route [_speakerphoneEnabled] asks for, and
|
||||
/// reports whether it landed.
|
||||
Future<bool> _configureAndroidVoiceRoute({required String phase}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
if (!_isAndroid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -717,23 +724,11 @@ class ChatVoiceAudioSessionCoordinator {
|
|||
phase: phase,
|
||||
);
|
||||
if (_speakerphoneEnabled) {
|
||||
// setSpeakerphoneOn is deprecated and is a no-op on Android 12+ once a
|
||||
// communication device is selected, so route to the built-in speaker
|
||||
// explicitly and keep the legacy call only as the pre-31 fallback.
|
||||
final routed = await _selectAndroidCommunicationDevice(
|
||||
final loudspeaker = await _routeAndroidToLoudspeaker(
|
||||
manager,
|
||||
AndroidAudioDeviceType.builtInSpeaker,
|
||||
phase: phase,
|
||||
);
|
||||
if (routed) {
|
||||
return modeSet;
|
||||
}
|
||||
final legacyRouted = await _safeAndroidRouteAction(
|
||||
() => manager.setSpeakerphoneOn(true),
|
||||
operation: 'configure-speakerphone',
|
||||
phase: phase,
|
||||
);
|
||||
return modeSet && legacyRouted;
|
||||
return modeSet && loudspeaker;
|
||||
}
|
||||
|
||||
await _safeAndroidRouteCall(
|
||||
|
|
@ -766,6 +761,91 @@ class ChatVoiceAudioSessionCoordinator {
|
|||
return modeSet;
|
||||
}
|
||||
|
||||
/// Puts the call on the built-in speaker and reports whether it is there.
|
||||
///
|
||||
/// Neither route call answers honestly on its own: `setSpeakerphoneOn` is
|
||||
/// void, and `setCommunicationDevice` can say yes and still be overridden
|
||||
/// (issue #716). The route is read back after each and only counts as
|
||||
/// applied when the read-back shows the loudspeaker. A read-back that fails
|
||||
/// outright says nothing, so that case trusts the set call as before.
|
||||
Future<bool> _routeAndroidToLoudspeaker(
|
||||
AndroidAudioManager manager, {
|
||||
required String phase,
|
||||
}) async {
|
||||
// setSpeakerphoneOn is deprecated and is a no-op on Android 12+ once a
|
||||
// communication device is selected, so route to the built-in speaker
|
||||
// explicitly and keep the legacy call as the pre-31 fallback.
|
||||
final routed = await _selectAndroidCommunicationDevice(
|
||||
manager,
|
||||
AndroidAudioDeviceType.builtInSpeaker,
|
||||
phase: phase,
|
||||
);
|
||||
var loudspeaker = false;
|
||||
String? deviceReadBack;
|
||||
bool? speakerphoneReadBack;
|
||||
if (routed) {
|
||||
final selected = await _safeAndroidRouteCall(
|
||||
() async => (device: await manager.getCommunicationDevice()),
|
||||
operation: 'get-communication-device',
|
||||
phase: phase,
|
||||
);
|
||||
if (selected == null) {
|
||||
loudspeaker = true;
|
||||
} else {
|
||||
loudspeaker =
|
||||
selected.device?.type == AndroidAudioDeviceType.builtInSpeaker;
|
||||
deviceReadBack = selected.device?.type.name ?? 'none';
|
||||
}
|
||||
}
|
||||
if (!loudspeaker) {
|
||||
if (routed) {
|
||||
// The platform accepted the speaker but the read-back disagrees.
|
||||
// Release that selection first: setSpeakerphoneOn is ignored while a
|
||||
// communication device stays selected, so the fallback would not
|
||||
// move the route either.
|
||||
await _safeAndroidRouteCall(
|
||||
() => manager.clearCommunicationDevice(),
|
||||
operation: 'clear-rejected-communication-device',
|
||||
phase: phase,
|
||||
);
|
||||
}
|
||||
final legacyRouted = await _safeAndroidRouteAction(
|
||||
() => manager.setSpeakerphoneOn(true),
|
||||
operation: 'configure-speakerphone',
|
||||
phase: phase,
|
||||
);
|
||||
if (legacyRouted) {
|
||||
final speakerphoneOn = await _safeAndroidRouteCall(
|
||||
() => manager.isSpeakerphoneOn(),
|
||||
operation: 'get-speakerphone',
|
||||
phase: phase,
|
||||
);
|
||||
loudspeaker = speakerphoneOn ?? true;
|
||||
speakerphoneReadBack = speakerphoneOn;
|
||||
}
|
||||
}
|
||||
final data = <String, Object?>{
|
||||
'phase': phase,
|
||||
'communicationDevice': routed,
|
||||
'deviceReadBack': deviceReadBack,
|
||||
'speakerphoneReadBack': speakerphoneReadBack,
|
||||
};
|
||||
if (loudspeaker) {
|
||||
DebugLogger.info(
|
||||
'android-loudspeaker-route-applied',
|
||||
scope: 'voice/audio-route',
|
||||
data: data,
|
||||
);
|
||||
} else {
|
||||
DebugLogger.warning(
|
||||
'android-loudspeaker-route-refused',
|
||||
scope: 'voice/audio-route',
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
return loudspeaker;
|
||||
}
|
||||
|
||||
Future<bool> _selectAndroidCommunicationDevice(
|
||||
AndroidAudioManager manager,
|
||||
AndroidAudioDeviceType type, {
|
||||
|
|
@ -799,7 +879,7 @@ class ChatVoiceAudioSessionCoordinator {
|
|||
}
|
||||
|
||||
Future<void> _restoreAndroidVoiceRoute() async {
|
||||
if (!Platform.isAndroid) {
|
||||
if (!_isAndroid) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1550,7 +1550,10 @@ class ChatVoiceModeController extends Notifier<ChatVoiceModeSnapshot> {
|
|||
return;
|
||||
}
|
||||
_handleAssistantContentChanged(messages);
|
||||
if (active.isStreaming) {
|
||||
// The transport marks `responseDone` on a terminal finish reason while
|
||||
// `isStreaming` can stay true until a later done event. Finalize on either
|
||||
// signal so the trailing TTS chunk is not held until transport close.
|
||||
if (!assistantMessageResponseCompleted(active)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1710,7 +1713,16 @@ class ChatVoiceModeController extends Notifier<ChatVoiceModeSnapshot> {
|
|||
String? _visibleAssistantText([List<ChatMessage>? messages]) {
|
||||
final message = _activeAssistantMessage(messages);
|
||||
if (message == null) return null;
|
||||
if (message.isStreaming && _isLastStreamingAssistant(message, messages)) {
|
||||
// Once the transport marks the response done it has flushed the buffered
|
||||
// text into the message, while `streamingContentProvider` may still hold
|
||||
// an earlier frame. The message is authoritative from that point.
|
||||
final settled =
|
||||
message.isStreaming &&
|
||||
assistantMessageResponseCompleted(message) &&
|
||||
message.content.isNotEmpty;
|
||||
if (!settled &&
|
||||
message.isStreaming &&
|
||||
_isLastStreamingAssistant(message, messages)) {
|
||||
final visible = ref.read(streamingContentProvider);
|
||||
if (visible != null && visible.isNotEmpty) {
|
||||
return visible;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../shared/models/connection_attempt.dart';
|
||||
import '../models/direct_connection_profile.dart';
|
||||
import '../models/direct_remote_model.dart';
|
||||
import '../services/direct_adapter_helpers.dart';
|
||||
import 'direct_connection_editor_draft.dart';
|
||||
import 'direct_connection_editor_form.dart';
|
||||
|
||||
|
|
@ -448,6 +450,7 @@ final class DirectConnectionEditorWorkflow extends ChangeNotifier {
|
|||
return _unavailable(messages);
|
||||
} catch (error) {
|
||||
if (!_canContinue()) return _unavailable(messages);
|
||||
_logOperationFailure('Direct profile save failed', error, draft);
|
||||
_finishOperation(error: messages.saveFailed);
|
||||
return DirectEditorActionResult(
|
||||
DirectEditorActionOutcome.failed,
|
||||
|
|
@ -456,6 +459,24 @@ final class DirectConnectionEditorWorkflow extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
/// Logs a failed editor operation without leaking the draft's credentials
|
||||
/// or endpoint. The message is redacted against every sensitive profile
|
||||
/// value and stripped of URLs before it is emitted.
|
||||
void _logOperationFailure(
|
||||
String message,
|
||||
Object error,
|
||||
DirectConnectionProfile draft,
|
||||
) {
|
||||
DebugLogger.error(
|
||||
message,
|
||||
scope: 'direct/editor',
|
||||
data: {
|
||||
'errorType': error.runtimeType.toString(),
|
||||
'message': _redactedFailureMessage(error, draft),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<DirectEditorActionResult> testConnection({
|
||||
required DirectEditorMessages messages,
|
||||
required DirectCredentialTransferConfirmation confirmCredentialTransfer,
|
||||
|
|
@ -508,6 +529,7 @@ final class DirectConnectionEditorWorkflow extends ChangeNotifier {
|
|||
);
|
||||
} catch (error) {
|
||||
if (!_canContinue()) return _unavailable(messages);
|
||||
_logOperationFailure('Direct connection probe threw', error, draft);
|
||||
_finishOperation(
|
||||
attempt: ConnectionAttemptState.failed(messages.reachFailed),
|
||||
);
|
||||
|
|
@ -666,3 +688,17 @@ final class DirectConnectionEditorWorkflow extends ChangeNotifier {
|
|||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final RegExp _urlPattern = RegExp(r'[A-Za-z][A-Za-z0-9+.-]*://\S+');
|
||||
|
||||
String _redactedFailureMessage(Object error, DirectConnectionProfile draft) {
|
||||
final sanitized = sanitizeDirectProviderErrorMessage(
|
||||
error.toString(),
|
||||
sensitiveValues: [
|
||||
...directProfileSensitiveValues(draft),
|
||||
draft.baseUrl.trim(),
|
||||
],
|
||||
maxCharacters: 200,
|
||||
);
|
||||
return sanitized.replaceAll(_urlPattern, '[URL]');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -451,21 +451,43 @@ class DirectConnectionProfilesController
|
|||
extends AsyncNotifier<List<DirectConnectionProfile>> {
|
||||
Future<void> _mutationQueue = Future<void>.value();
|
||||
bool _appDataClearBlocked = false;
|
||||
bool _durableLogoutFenceBlocked = false;
|
||||
bool _incompleteAppDataClearBlocked = false;
|
||||
List<DirectConnectionProfile>? _profilesBeforeAppDataClear;
|
||||
|
||||
bool get _mutationsBlocked =>
|
||||
_appDataClearBlocked || _durableLogoutFenceBlocked;
|
||||
_appDataClearBlocked || _incompleteAppDataClearBlocked;
|
||||
|
||||
DirectConnectionProfileStore get _store =>
|
||||
ref.read(directConnectionProfileStoreProvider);
|
||||
|
||||
/// Direct profiles live in their own secure-storage document with their own
|
||||
/// credentials. The Open WebUI incomplete-logout fence
|
||||
/// ([incompleteLogoutFenceProvider]) guards Open WebUI token/cookie
|
||||
/// restoration, so an ordinary failed Open WebUI sign-out must not hide
|
||||
/// Direct profiles or block Direct setup.
|
||||
///
|
||||
/// A full app-data clear that the user requested is different: when it
|
||||
/// fails part-way ([revokeRuntimeAfterIncompleteAppDataClear]), profiles the
|
||||
/// wipe could not remove stay hidden and unusable, in memory and across
|
||||
/// restarts ([PreferenceKeys.incompleteAppDataClear]), until the durable
|
||||
/// logout fence is cleared by a completed cleanup or a new authenticated
|
||||
/// session.
|
||||
@override
|
||||
Future<List<DirectConnectionProfile>> build() {
|
||||
_durableLogoutFenceBlocked = ref.watch(incompleteLogoutFenceProvider);
|
||||
if (_durableLogoutFenceBlocked) {
|
||||
ref.read(directRunRegistryProvider).blockAdmissionForAppDataClear();
|
||||
return Future.value(const []);
|
||||
final logoutFenceActive = ref.watch(incompleteLogoutFenceProvider);
|
||||
final incompleteClearPersisted =
|
||||
PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear) ??
|
||||
false;
|
||||
if (_incompleteAppDataClearBlocked || incompleteClearPersisted) {
|
||||
if (logoutFenceActive) {
|
||||
_incompleteAppDataClearBlocked = true;
|
||||
ref.read(directRunRegistryProvider).blockAdmissionForAppDataClear();
|
||||
return Future.value(const <DirectConnectionProfile>[]);
|
||||
}
|
||||
_incompleteAppDataClearBlocked = false;
|
||||
if (incompleteClearPersisted) {
|
||||
unawaited(_clearIncompleteAppDataClearMarker());
|
||||
}
|
||||
}
|
||||
if (_appDataClearBlocked) {
|
||||
ref.read(directRunRegistryProvider).blockAdmissionForAppDataClear();
|
||||
|
|
@ -647,35 +669,98 @@ class DirectConnectionProfilesController
|
|||
});
|
||||
}
|
||||
|
||||
/// Probes [profile] and always resolves to a [DirectConnectionProbe].
|
||||
///
|
||||
/// Pre-flight failures (validation, missing adapter, an in-flight app-data
|
||||
/// clear) are reported as `reachable: false` with a specific message rather
|
||||
/// than thrown, so the editor can show the real cause instead of a generic
|
||||
/// "could not save" fallback.
|
||||
Future<DirectConnectionProbe> probe(DirectConnectionProfile profile) async {
|
||||
_ensureMounted();
|
||||
if (_mutationsBlocked) {
|
||||
throw StateError(
|
||||
'Direct connection probes are blocked while app data is being cleared.',
|
||||
);
|
||||
}
|
||||
profile.validate();
|
||||
final adapter = ref
|
||||
.read(directProviderAdapterRegistryProvider)
|
||||
.require(profile.adapterKey);
|
||||
try {
|
||||
_ensureMounted();
|
||||
if (_mutationsBlocked) {
|
||||
return const DirectConnectionProbe(
|
||||
reachable: false,
|
||||
message:
|
||||
'Direct connection probes are unavailable while app data is '
|
||||
'being cleared.',
|
||||
);
|
||||
}
|
||||
final validationError = profile.validateOrNull();
|
||||
if (validationError != null) {
|
||||
return DirectConnectionProbe(
|
||||
reachable: false,
|
||||
message: _sanitizeRuntimeAdapterMessage(profile, validationError),
|
||||
);
|
||||
}
|
||||
final adapter = ref
|
||||
.read(directProviderAdapterRegistryProvider)
|
||||
.lookup(profile.adapterKey);
|
||||
if (adapter == null) {
|
||||
return const DirectConnectionProbe(
|
||||
reachable: false,
|
||||
message: 'This provider type is not available on this device.',
|
||||
);
|
||||
}
|
||||
final result = await adapter.probe(profile);
|
||||
final message = result.message;
|
||||
if (message == null) return result;
|
||||
return DirectConnectionProbe(
|
||||
reachable: result.reachable,
|
||||
modelCount: result.modelCount,
|
||||
message: _sanitizeRuntimeAdapterMessage(profile, message),
|
||||
message: result.reachable
|
||||
? _sanitizeRuntimeAdapterMessage(profile, message)
|
||||
: _probeFailureMessage(profile, message),
|
||||
);
|
||||
} catch (error) {
|
||||
final normalized = normalizeDirectProviderError(error);
|
||||
DebugLogger.warning(
|
||||
'Direct connection probe failed',
|
||||
scope: 'direct/profiles',
|
||||
data: {'errorType': error.runtimeType.toString()},
|
||||
);
|
||||
return DirectConnectionProbe(
|
||||
reachable: false,
|
||||
message: _sanitizeRuntimeAdapterMessage(profile, normalized.message),
|
||||
message: _probeFailureMessage(
|
||||
profile,
|
||||
normalized.message,
|
||||
statusCode: normalized.statusCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitizes an untrusted provider failure and, for HTTP 401/403 under the
|
||||
/// Azure API-key header mode, appends the trusted Bearer hint after
|
||||
/// redaction so the combined text stays within the sanitizer budget.
|
||||
String _probeFailureMessage(
|
||||
DirectConnectionProfile profile,
|
||||
String message, {
|
||||
int? statusCode,
|
||||
}) {
|
||||
final hinted = directAuthModeHintApplies(
|
||||
message,
|
||||
profile: profile,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
final sanitized = sanitizeDirectProviderErrorMessage(
|
||||
message,
|
||||
sensitiveValues: directProfileSensitiveValues(profile),
|
||||
maxCharacters: hinted
|
||||
? kMaxDirectProviderErrorCharacters -
|
||||
kDirectBearerAuthModeHint.length -
|
||||
2
|
||||
: kMaxDirectProviderErrorCharacters,
|
||||
);
|
||||
return hinted
|
||||
? appendDirectAuthModeHint(
|
||||
sanitized,
|
||||
profile: profile,
|
||||
statusCode: statusCode,
|
||||
)
|
||||
: sanitized;
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
final resources = _captureMutationResources();
|
||||
await _serializeMutation(() => _reload(resources));
|
||||
|
|
@ -769,12 +854,9 @@ class DirectConnectionProfilesController
|
|||
void resumeMutationsAfterAppDataClearAbort() {
|
||||
if (ref.mounted) {
|
||||
_appDataClearBlocked = false;
|
||||
final runRegistry = ref.read(directRunRegistryProvider);
|
||||
if (_durableLogoutFenceBlocked) {
|
||||
runRegistry.blockAdmissionForAppDataClear();
|
||||
} else {
|
||||
runRegistry.resumeAdmissionAfterAppDataClearAbort();
|
||||
}
|
||||
ref
|
||||
.read(directRunRegistryProvider)
|
||||
.resumeAdmissionAfterAppDataClearAbort();
|
||||
final previous = _profilesBeforeAppDataClear;
|
||||
if (previous != null) {
|
||||
state = AsyncValue.data(previous);
|
||||
|
|
@ -783,32 +865,47 @@ class DirectConnectionProfilesController
|
|||
}
|
||||
}
|
||||
|
||||
/// Drops every in-memory transport authority after a partial wipe while the
|
||||
/// durable incomplete-logout fence keeps the controller blocked.
|
||||
void revokeRuntimeAfterIncompleteAppDataClear() {
|
||||
/// Drops every in-memory transport authority after a partial wipe and keeps
|
||||
/// the controller blocked until the durable logout fence is cleared.
|
||||
///
|
||||
/// The user asked for these credentials to be erased. Profiles the wipe
|
||||
/// failed to remove must not come back for new completions, so the block
|
||||
/// outlives the transient preparation flag and is persisted for restarts;
|
||||
/// [build] releases it once the fence is cleared by a completed cleanup or
|
||||
/// a new authenticated session. The returned future completes once the
|
||||
/// restart marker is durable, or has failed and been logged; the in-memory
|
||||
/// block holds either way for this process.
|
||||
Future<void> revokeRuntimeAfterIncompleteAppDataClear() async {
|
||||
if (!ref.mounted) return;
|
||||
// The durable logout fence now owns the long-lived block. Releasing the
|
||||
// transient preparation flag lets a later successful login resume this
|
||||
// controller when that durable fence is cleared.
|
||||
_appDataClearBlocked = false;
|
||||
_incompleteAppDataClearBlocked = true;
|
||||
_profilesBeforeAppDataClear = null;
|
||||
final current = state.value ?? const <DirectConnectionProfile>[];
|
||||
final clientPool = ref.read(directHttpClientPoolProvider);
|
||||
final modelRegistry = ref.read(directModelRegistryProvider);
|
||||
final runRegistry = ref.read(directRunRegistryProvider);
|
||||
runRegistry.blockAdmissionForAppDataClear();
|
||||
for (final profile in current) {
|
||||
_invalidateDirectProfileTransportBestEffort(clientPool, profile.id);
|
||||
_removeProfileModelsBestEffort(modelRegistry, profile.id);
|
||||
_cancelProfileRunsBestEffort(runRegistry, profile.id);
|
||||
}
|
||||
state = const AsyncValue.data([]);
|
||||
// Re-assert the marker armed before the wipe: the wipe itself may have
|
||||
// removed it. A failure propagates so the clear is not reported as
|
||||
// handled while the restart fence is missing.
|
||||
await armIncompleteAppDataClearMarker();
|
||||
}
|
||||
|
||||
Future<void> _clearIncompleteAppDataClearMarker() =>
|
||||
disarmIncompleteAppDataClearMarker();
|
||||
|
||||
_DirectProfileMutationResources _captureMutationResources() {
|
||||
_ensureMounted();
|
||||
if (_mutationsBlocked) {
|
||||
throw StateError(
|
||||
'Direct connection changes are unavailable while signing out.',
|
||||
'Direct connection changes are unavailable while app data is being '
|
||||
'cleared.',
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
|
@ -2572,3 +2669,48 @@ void _cancelDirectProfileRunsBestEffort(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Durably marks that a full app-data clear is in progress or ended
|
||||
/// incomplete, so [DirectConnectionProfilesController.build] keeps surviving
|
||||
/// Direct profiles hidden after a restart until the logout fence is cleared.
|
||||
///
|
||||
/// Written through the app-data-clear write barrier with a checked write and
|
||||
/// awaited by the caller. A failure throws: the clear must not proceed, or be
|
||||
/// reported as handled, without the restart fence in place.
|
||||
Future<void> armIncompleteAppDataClearMarker() async {
|
||||
try {
|
||||
await PreferencesStore.putCheckedIf(
|
||||
PreferenceKeys.incompleteAppDataClear,
|
||||
true,
|
||||
canWrite: () => true,
|
||||
bypassAppDataClearBarrier: true,
|
||||
);
|
||||
} catch (error) {
|
||||
DebugLogger.error(
|
||||
'Failed to persist the incomplete app-data-clear marker',
|
||||
scope: 'direct/profiles',
|
||||
data: {'errorType': error.runtimeType.toString()},
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the restart marker after a clear completed or was abandoned before
|
||||
/// touching any data. Best effort: a stale marker only keeps Direct profiles
|
||||
/// hidden until the logout fence is observed clear.
|
||||
Future<void> disarmIncompleteAppDataClearMarker() async {
|
||||
try {
|
||||
await PreferencesStore.putCheckedIf(
|
||||
PreferenceKeys.incompleteAppDataClear,
|
||||
null,
|
||||
canWrite: () => true,
|
||||
bypassAppDataClearBarrier: true,
|
||||
);
|
||||
} catch (error) {
|
||||
DebugLogger.warning(
|
||||
'Failed to clear the incomplete app-data-clear marker',
|
||||
scope: 'direct/profiles',
|
||||
data: {'errorType': error.runtimeType.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -634,6 +634,56 @@ DirectProviderException normalizeDirectProviderError(Object error) {
|
|||
return DirectProviderException('The provider request failed.', cause: error);
|
||||
}
|
||||
|
||||
/// Hint appended to an authentication failure when the profile sends its
|
||||
/// credential in the Azure `api-key` header. Most OpenAI-compatible servers
|
||||
/// (llama.cpp, vLLM, LM Studio, Ollama) only honor `Authorization: Bearer`.
|
||||
const String kDirectBearerAuthModeHint =
|
||||
'This provider may expect the Bearer authentication mode.';
|
||||
|
||||
final RegExp _directAuthFailureStatusPattern = RegExp(r'\bHTTP (401|403)\b');
|
||||
|
||||
/// Whether [message] (or [statusCode]) reports an HTTP 401/403 while
|
||||
/// [profile] sends its credential in the Azure API-key header.
|
||||
///
|
||||
/// Adapters may surface the failure either as a thrown
|
||||
/// [DirectProviderException] (carrying [statusCode]) or as a returned probe
|
||||
/// whose message came from [normalizeDirectProviderError]; the message text is
|
||||
/// the common denominator, so both shapes are recognized.
|
||||
bool directAuthModeHintApplies(
|
||||
String message, {
|
||||
required DirectConnectionProfile profile,
|
||||
int? statusCode,
|
||||
}) {
|
||||
if (profile.apiKeyAuthMode != DirectApiKeyAuthMode.apiKeyHeader) {
|
||||
return false;
|
||||
}
|
||||
if (message.contains(kDirectBearerAuthModeHint)) return false;
|
||||
return statusCode == 401 ||
|
||||
statusCode == 403 ||
|
||||
_directAuthFailureStatusPattern.hasMatch(message);
|
||||
}
|
||||
|
||||
/// Appends [kDirectBearerAuthModeHint] to an already sanitized [message] when
|
||||
/// [directAuthModeHintApplies]. Call this after
|
||||
/// [sanitizeDirectProviderErrorMessage]: the hint is trusted app text, and the
|
||||
/// sanitizer's Authorization-header redaction would otherwise mangle it.
|
||||
String appendDirectAuthModeHint(
|
||||
String message, {
|
||||
required DirectConnectionProfile profile,
|
||||
int? statusCode,
|
||||
}) {
|
||||
if (!directAuthModeHintApplies(
|
||||
message,
|
||||
profile: profile,
|
||||
statusCode: statusCode,
|
||||
)) {
|
||||
return message;
|
||||
}
|
||||
final trimmed = message.trimRight();
|
||||
final separator = trimmed.isEmpty ? '' : (trimmed.endsWith('.') ? ' ' : '. ');
|
||||
return '$trimmed$separator$kDirectBearerAuthModeHint';
|
||||
}
|
||||
|
||||
const int kMaxDirectProviderErrorCharacters = 512;
|
||||
const String _redactedProviderValue = '[REDACTED]';
|
||||
const int _maxProviderErrorSecretPatterns = 128;
|
||||
|
|
|
|||
|
|
@ -149,6 +149,9 @@ final class OpenAiCompatibleAdapter implements DirectProviderAdapter {
|
|||
?.toString()
|
||||
.trim();
|
||||
if (id == null || id.isEmpty || !seen.add(id)) continue;
|
||||
// OpenRouter lists `:batch` variants that only accept the Batch API
|
||||
// and return 404 on chat completions, so they are not usable here.
|
||||
if (profile.isOpenRouter && id.endsWith(':batch')) continue;
|
||||
|
||||
// Compatible providers frequently omit OpenAI's otherwise-required
|
||||
// object field. Normalize only that protocol detail, then let the SDK
|
||||
|
|
|
|||
|
|
@ -403,8 +403,11 @@ final class DirectConnectionDetailsSection extends StatelessWidget {
|
|||
return InsetGroupedList(
|
||||
footer: [
|
||||
baseUrlDescription,
|
||||
if (form.authentication == DirectAuthenticationMode.bearer &&
|
||||
form.canSelectApiKeyHeader)
|
||||
l10n.directBearerDescription,
|
||||
if (form.authentication == DirectAuthenticationMode.apiKeyHeader)
|
||||
l10n.directApiKeyHeaderDescription,
|
||||
l10n.directApiKeyHeaderAzureOnlyDescription,
|
||||
if (form.authentication == DirectAuthenticationMode.unsupported)
|
||||
l10n.openWebUiDirectConnectionUnsupportedAuth,
|
||||
].join('\n\n'),
|
||||
|
|
@ -446,10 +449,20 @@ final class DirectConnectionDetailsSection extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
authenticationField,
|
||||
if (form.authentication == DirectAuthenticationMode.bearer &&
|
||||
form.canSelectApiKeyHeader) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
l10n.directBearerDescription,
|
||||
style: AppTypography.bodySmallStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (form.authentication == DirectAuthenticationMode.apiKeyHeader) ...[
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
l10n.directApiKeyHeaderDescription,
|
||||
l10n.directApiKeyHeaderAzureOnlyDescription,
|
||||
style: AppTypography.bodySmallStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1414,29 +1414,54 @@ class HermesApiService implements HermesBackendService, HermesTurnService {
|
|||
maxDuration: timeout,
|
||||
clock: _recoveryClock,
|
||||
);
|
||||
final resp = await _getRecoveryResponse(
|
||||
'$_root/api/sessions/$encodedId/messages',
|
||||
cancelToken: requestCancelToken,
|
||||
recoveryBudget: budget,
|
||||
limits: limits,
|
||||
);
|
||||
final data = await _decodeHermesBoundedJsonValue(
|
||||
resp.data,
|
||||
cancelToken: requestCancelToken,
|
||||
limits: limits,
|
||||
recoveryBudget: budget,
|
||||
);
|
||||
final Object? data;
|
||||
try {
|
||||
final resp = await _getRecoveryResponse(
|
||||
'$_root/api/sessions/$encodedId/messages',
|
||||
cancelToken: requestCancelToken,
|
||||
recoveryBudget: budget,
|
||||
limits: limits,
|
||||
);
|
||||
data = await _decodeHermesBoundedJsonValue(
|
||||
resp.data,
|
||||
cancelToken: requestCancelToken,
|
||||
limits: limits,
|
||||
recoveryBudget: budget,
|
||||
);
|
||||
} on HermesStreamGuardException catch (error, stackTrace) {
|
||||
// Size-class guards become the shared "too large" type so the session
|
||||
// list can tell "this conversation is too big" from a connection fault.
|
||||
if (_isHermesSizeGuard(error)) {
|
||||
Error.throwWithStackTrace(
|
||||
HermesResponseTooLargeException(error.message),
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
final list = data is Map ? (data['messages'] ?? data['data']) : data;
|
||||
if (list is! List) return const [];
|
||||
if (list.length > kMaxHermesSessionHistoryMessages) {
|
||||
_signalHermesStreamCancellation(requestCancelToken);
|
||||
throw const HermesStreamGuardException(
|
||||
throw const HermesResponseTooLargeException(
|
||||
'The Hermes session history exceeded Conduit\'s message limit.',
|
||||
);
|
||||
}
|
||||
return list.whereType<Map>().map((m) => m.cast<String, dynamic>()).toList();
|
||||
}
|
||||
|
||||
/// Whether a recovery guard tripped on payload size rather than time.
|
||||
static bool _isHermesSizeGuard(HermesStreamGuardException error) {
|
||||
const sizeLimits = [
|
||||
'transfer limit',
|
||||
'size limit',
|
||||
'nesting limit',
|
||||
'value limit',
|
||||
'token limit',
|
||||
];
|
||||
return sizeLimits.any(error.message.contains);
|
||||
}
|
||||
|
||||
/// Starts one turn through Hermes's existing Responses SSE endpoint.
|
||||
///
|
||||
/// Unlike `/v1/runs`, Responses accepts multimodal content on current Hermes
|
||||
|
|
|
|||
|
|
@ -113,16 +113,43 @@ List<Map<String, dynamic>> parseHermesDesktopConfiguredModels(
|
|||
return models;
|
||||
}
|
||||
|
||||
/// Default and smallest page sizes for `GET /api/sessions/{id}/messages`.
|
||||
///
|
||||
/// A single 500-message page of a long tool-heavy conversation can exceed the
|
||||
/// REST transport's character cap, so the pager starts smaller and shrinks
|
||||
/// further whenever a page is rejected as too large.
|
||||
const int kHermesDesktopTranscriptPageSize = 200;
|
||||
const int kHermesDesktopTranscriptMinimumPageSize = 25;
|
||||
|
||||
@visibleForTesting
|
||||
Future<List<Map<String, dynamic>>> loadHermesDesktopTranscriptPages(
|
||||
Future<List<Map<String, dynamic>>> Function(int offset, int limit) loadPage, {
|
||||
int maximum = 10000,
|
||||
int pageSize = kHermesDesktopTranscriptPageSize,
|
||||
int minimumPageSize = kHermesDesktopTranscriptMinimumPageSize,
|
||||
}) async {
|
||||
const pageSize = 500;
|
||||
assert(pageSize > 0 && minimumPageSize > 0);
|
||||
final messages = <Map<String, dynamic>>[];
|
||||
var currentPageSize = pageSize;
|
||||
while (messages.length < maximum) {
|
||||
final limit = min(pageSize, maximum - messages.length);
|
||||
final page = await loadPage(messages.length, limit);
|
||||
final limit = min(currentPageSize, maximum - messages.length);
|
||||
final List<Map<String, dynamic>> page;
|
||||
try {
|
||||
page = await loadPage(messages.length, limit);
|
||||
} on HermesResponseTooLargeException {
|
||||
if (limit <= minimumPageSize) rethrow;
|
||||
currentPageSize = max(minimumPageSize, limit ~/ 2);
|
||||
DebugLogger.warning(
|
||||
'transcript-page-too-large',
|
||||
scope: 'hermes/desktop',
|
||||
data: {
|
||||
'offset': messages.length,
|
||||
'limit': limit,
|
||||
'retryLimit': currentPageSize,
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
messages.addAll(page.take(limit));
|
||||
if (page.length < limit) break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,12 +260,12 @@ extension _HermesDesktopAuthRest on HermesDesktopApiService {
|
|||
);
|
||||
final bytes = response.data ?? const <int>[];
|
||||
if (bytes.length > kMaxHermesDesktopFrameBytes) {
|
||||
throw const FormatException('Hermes response is too large.');
|
||||
throw const HermesResponseTooLargeException();
|
||||
}
|
||||
if (bytes.isEmpty) return null;
|
||||
final source = utf8.decode(bytes);
|
||||
if (source.length > kMaxHermesDesktopFrameCharacters) {
|
||||
throw const FormatException('Hermes response is too large.');
|
||||
throw const HermesResponseTooLargeException();
|
||||
}
|
||||
validateHermesJsonSource(source);
|
||||
return jsonDecode(source);
|
||||
|
|
|
|||
|
|
@ -397,20 +397,44 @@ extension _HermesDesktopLiveRuntime on HermesDesktopApiService {
|
|||
_bindingSocketGenerations[id] == _rpc.socketGeneration) {
|
||||
return const [];
|
||||
}
|
||||
final binding = await _resume(id, refresh: true);
|
||||
final hadBufferedEvent = _eventBuffer.take(binding.runtimeId).isNotEmpty;
|
||||
final isBotChat = _sessionProfiles.containsKey(binding.storedId);
|
||||
// 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) {
|
||||
if (isBotChat && runtimeId != null) {
|
||||
if (cancelToken?.isCancelled == true) throw cancelToken!.cancelError!;
|
||||
final messages = _objects(
|
||||
await _rpc.request<Object?>(
|
||||
'session.history',
|
||||
params: {
|
||||
'session_id': binding.runtimeId,
|
||||
..._sessionScope(binding.storedId),
|
||||
},
|
||||
params: {'session_id': runtimeId, ..._sessionScope(storedId)},
|
||||
),
|
||||
'messages',
|
||||
);
|
||||
|
|
@ -421,13 +445,13 @@ extension _HermesDesktopLiveRuntime on HermesDesktopApiService {
|
|||
(offset, limit) async => _objects(
|
||||
await _requestJson(
|
||||
'GET',
|
||||
'/api/sessions/${Uri.encodeComponent(binding.storedId)}/messages',
|
||||
'/api/sessions/${Uri.encodeComponent(storedId)}/messages',
|
||||
query: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'order': 'oldest',
|
||||
'include_compacted': true,
|
||||
..._sessionScope(binding.storedId),
|
||||
..._sessionScope(storedId),
|
||||
},
|
||||
cancelToken: cancelToken,
|
||||
),
|
||||
|
|
@ -440,11 +464,15 @@ extension _HermesDesktopLiveRuntime on HermesDesktopApiService {
|
|||
var messages = const <Map<String, dynamic>>[];
|
||||
for (var attempt = 0; attempt < 3; attempt++) {
|
||||
messages = await load();
|
||||
final arrivedDuringLoad = _eventBuffer.take(binding.runtimeId).isNotEmpty;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -265,7 +265,9 @@ final class HermesDesktopRpcClient {
|
|||
source = raw;
|
||||
} else if (raw is List<int>) {
|
||||
if (raw.length > kMaxHermesDesktopFrameBytes) {
|
||||
throw const FormatException('Hermes gateway frame is too large.');
|
||||
throw const HermesResponseTooLargeException(
|
||||
'Hermes gateway frame is too large.',
|
||||
);
|
||||
}
|
||||
source = utf8.decode(raw);
|
||||
} else {
|
||||
|
|
@ -274,7 +276,9 @@ final class HermesDesktopRpcClient {
|
|||
if (source.length > kMaxHermesDesktopFrameCharacters ||
|
||||
(source.length * 3 > kMaxHermesDesktopFrameBytes &&
|
||||
utf8.encode(source).length > kMaxHermesDesktopFrameBytes)) {
|
||||
throw const FormatException('Hermes gateway frame is too large.');
|
||||
throw const HermesResponseTooLargeException(
|
||||
'Hermes gateway frame is too large.',
|
||||
);
|
||||
}
|
||||
validateHermesJsonSource(source);
|
||||
final decoded = jsonDecode(source);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,27 @@ final class HermesJsonGuardException implements FormatException {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:dio/dio.dart' show DioException;
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
|
@ -21,7 +22,10 @@ import '../models/hermes_bot.dart';
|
|||
import '../providers/hermes_providers.dart';
|
||||
import '../services/hermes_backend_service.dart';
|
||||
import '../services/hermes_desktop_api_service.dart';
|
||||
import '../services/hermes_desktop_transport.dart';
|
||||
import '../services/hermes_decision_projection.dart';
|
||||
import '../services/hermes_identifier.dart';
|
||||
import '../services/hermes_json_guard.dart';
|
||||
import '../services/hermes_local_document_trust_store.dart';
|
||||
import '../services/hermes_message_mapper.dart';
|
||||
import '../services/hermes_pending_decision_store.dart';
|
||||
|
|
@ -244,6 +248,54 @@ Future<void> deleteHermesSession(WidgetRef ref, String sessionId) async {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hermes gateway `session.resume` error code for an unknown session.
|
||||
const int kHermesDesktopSessionNotFoundCode = 4007;
|
||||
|
||||
const int _maxHermesLoadFailureDetailCharacters = 120;
|
||||
|
||||
/// Picks the user-facing message for a failed session open.
|
||||
///
|
||||
/// Server-provided text is bounded and stripped of control characters before
|
||||
/// it reaches the UI; anything unrecognised falls back to the generic message.
|
||||
@visibleForTesting
|
||||
String hermesSessionLoadFailureMessage(AppLocalizations l10n, Object error) {
|
||||
if (error is HermesResponseTooLargeException) {
|
||||
return l10n.hermesSessionTooLarge;
|
||||
}
|
||||
if (error is DioException && error.response?.statusCode == 404) {
|
||||
return l10n.hermesSessionNotFound;
|
||||
}
|
||||
if (error is HermesDesktopRpcException) {
|
||||
if (error.code == kHermesDesktopSessionNotFoundCode) {
|
||||
return l10n.hermesSessionNotFound;
|
||||
}
|
||||
if (error.deliveryAmbiguous) return l10n.hermesSessionLoadFailed;
|
||||
final detail = _hermesLoadFailureDetail(error.message);
|
||||
if (detail != null) {
|
||||
final code = error.code;
|
||||
return l10n.hermesSessionLoadFailedDetail(
|
||||
code == null ? detail : '$detail ($code)',
|
||||
);
|
||||
}
|
||||
}
|
||||
return l10n.hermesSessionLoadFailed;
|
||||
}
|
||||
|
||||
String? _hermesLoadFailureDetail(String raw) {
|
||||
final collapsed = raw
|
||||
.replaceAll(RegExp(r'[\u0000-\u001F\u007F-\u009F\s]+'), ' ')
|
||||
.trim();
|
||||
if (collapsed.isEmpty) return null;
|
||||
final runes = collapsed.runes.toList(growable: false);
|
||||
final truncated = runes.length > _maxHermesLoadFailureDetailCharacters
|
||||
? '${String.fromCharCodes(runes.take(_maxHermesLoadFailureDetailCharacters - 1))}\u2026'
|
||||
: collapsed;
|
||||
return validateHermesBoundedString(
|
||||
truncated,
|
||||
maxCharacters: _maxHermesLoadFailureDetailCharacters,
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads a Hermes session's transcript, binds it as the active chat, selects the
|
||||
/// Hermes model, and navigates to the chat view. Subsequent sends continue the
|
||||
/// same server-side session.
|
||||
|
|
@ -274,12 +326,16 @@ Future<void> openHermesSession(
|
|||
DebugLogger.error(
|
||||
'open-session-failed',
|
||||
scope: 'hermes/sessions',
|
||||
data: {'errorType': error.runtimeType.toString()},
|
||||
data: {
|
||||
'errorType': error.runtimeType.toString(),
|
||||
if (error is HermesDesktopRpcException) 'rpcCode': error.code,
|
||||
if (error is DioException) 'httpStatus': error.response?.statusCode,
|
||||
},
|
||||
);
|
||||
if (context.mounted) {
|
||||
UiUtils.showMessage(
|
||||
context,
|
||||
AppLocalizations.of(context)!.hermesSessionLoadFailed,
|
||||
hermesSessionLoadFailureMessage(AppLocalizations.of(context)!, error),
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,6 +188,9 @@ class DefaultModelBottomSheetState
|
|||
isSelected: isSelected,
|
||||
isAutoSelect: isAutoSelect,
|
||||
iconUrl: iconUrl,
|
||||
surfaceColor: context
|
||||
.conduitTheme
|
||||
.surfaceBackground,
|
||||
onTap: () {
|
||||
final selectedId = isAutoSelect
|
||||
? 'auto-select'
|
||||
|
|
|
|||
|
|
@ -4909,4 +4909,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "Tato konverzace je příliš velká na načtení v tomto zařízení."
|
||||
,"hermesSessionNotFound": "Tato konverzace už není na serveru Hermes dostupná."
|
||||
,"hermesSessionLoadFailedDetail": "Tuto konverzaci se nepodařilo načíst: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "Odešle přihlašovací údaje v hlavičce Authorization: Bearer. Většina serverů kompatibilních s OpenAI (llama.cpp, vLLM, LM Studio, Ollama) to očekává."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "Odešle přihlašovací údaje v hlavičce api-key. Používejte pouze pro Azure OpenAI; ostatní servery kompatibilní s OpenAI očekávají token Bearer."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3254,4 +3254,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "Diese Unterhaltung ist zu groß, um sie auf diesem Gerät zu laden."
|
||||
,"hermesSessionNotFound": "Diese Unterhaltung ist auf dem Hermes-Server nicht mehr verfügbar."
|
||||
,"hermesSessionLoadFailedDetail": "Diese Unterhaltung konnte nicht geladen werden: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "Sendet die Anmeldedaten im Header Authorization: Bearer. Die meisten OpenAI-kompatiblen Server (llama.cpp, vLLM, LM Studio, Ollama) erwarten das."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "Sendet die Anmeldedaten im Header api-key. Nur für Azure OpenAI verwenden; andere OpenAI-kompatible Server erwarten ein Bearer-Token."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5564,6 +5564,23 @@
|
|||
"@hermesSessionLoadFailed": {
|
||||
"description": "Error shown when a Hermes conversation cannot be loaded."
|
||||
},
|
||||
"hermesSessionTooLarge": "This conversation is too large to load on this device.",
|
||||
"@hermesSessionTooLarge": {
|
||||
"description": "Error shown when a Hermes conversation transcript exceeds the size the app can load."
|
||||
},
|
||||
"hermesSessionNotFound": "This conversation is no longer available on the Hermes server.",
|
||||
"@hermesSessionNotFound": {
|
||||
"description": "Error shown when the Hermes server reports that a conversation does not exist."
|
||||
},
|
||||
"hermesSessionLoadFailedDetail": "Could not load this conversation: {detail}",
|
||||
"@hermesSessionLoadFailedDetail": {
|
||||
"description": "Error shown when a Hermes conversation cannot be loaded and the server gave a reason.",
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hermesMemoryKeyFieldLabel": "Memory key",
|
||||
"@hermesMemoryKeyFieldLabel": {
|
||||
"description": "Label for the Hermes long-term memory key field."
|
||||
|
|
@ -6001,5 +6018,9 @@
|
|||
"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network.",
|
||||
"@directMcpInsecureCredentialsMessage": {"description":"Warning before allowing MCP credentials over non-loopback HTTP."},
|
||||
"directMcpInsecureCredentialsConfirm": "Send without encryption",
|
||||
"@directMcpInsecureCredentialsConfirm": {"description":"Action confirming MCP credentials may be sent over unencrypted HTTP."}
|
||||
"@directMcpInsecureCredentialsConfirm": {"description":"Action confirming MCP credentials may be sent over unencrypted HTTP."},
|
||||
"directBearerDescription": "Sends the credential as an Authorization: Bearer header. Most OpenAI-compatible servers (llama.cpp, vLLM, LM Studio, Ollama) expect this.",
|
||||
"@directBearerDescription": {"description":"Help text for the Bearer token authentication mode of a direct connection."},
|
||||
"directApiKeyHeaderAzureOnlyDescription": "Sends the credential in the api-key header. Use this only for Azure OpenAI; other OpenAI-compatible servers expect a Bearer token.",
|
||||
"@directApiKeyHeaderAzureOnlyDescription": {"description":"Help text for the Azure OpenAI api-key header authentication mode, noting it is Azure-only."}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3254,4 +3254,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "Esta conversación es demasiado grande para cargarla en este dispositivo."
|
||||
,"hermesSessionNotFound": "Esta conversación ya no está disponible en el servidor Hermes."
|
||||
,"hermesSessionLoadFailedDetail": "No se pudo cargar esta conversación: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "Envía la credencial en la cabecera Authorization: Bearer. La mayoría de los servidores compatibles con OpenAI (llama.cpp, vLLM, LM Studio, Ollama) lo esperan así."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "Envía la credencial en la cabecera api-key. Úsalo solo para Azure OpenAI; otros servidores compatibles con OpenAI esperan un token Bearer."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3254,4 +3254,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "Cette conversation est trop volumineuse pour être chargée sur cet appareil."
|
||||
,"hermesSessionNotFound": "Cette conversation n'est plus disponible sur le serveur Hermes."
|
||||
,"hermesSessionLoadFailedDetail": "Impossible de charger cette conversation : {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "Envoie l'identifiant dans l'en-tête Authorization: Bearer. La plupart des serveurs compatibles OpenAI (llama.cpp, vLLM, LM Studio, Ollama) l'attendent ainsi."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "Envoie l'identifiant dans l'en-tête api-key. À utiliser uniquement pour Azure OpenAI ; les autres serveurs compatibles OpenAI attendent un jeton Bearer."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3254,4 +3254,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "Questa conversazione è troppo grande per essere caricata su questo dispositivo."
|
||||
,"hermesSessionNotFound": "Questa conversazione non è più disponibile sul server Hermes."
|
||||
,"hermesSessionLoadFailedDetail": "Impossibile caricare questa conversazione: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "Invia la credenziale nell'intestazione Authorization: Bearer. La maggior parte dei server compatibili con OpenAI (llama.cpp, vLLM, LM Studio, Ollama) si aspetta questo."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "Invia la credenziale nell'intestazione api-key. Usalo solo per Azure OpenAI; gli altri server compatibili con OpenAI si aspettano un token Bearer."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4594,4 +4594,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "この会話は大きすぎるため、このデバイスでは読み込めません。"
|
||||
,"hermesSessionNotFound": "この会話は Hermes サーバー上で利用できなくなりました。"
|
||||
,"hermesSessionLoadFailedDetail": "この会話を読み込めませんでした: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "認証情報を Authorization: Bearer ヘッダーで送信します。ほとんどの OpenAI 互換サーバー(llama.cpp、vLLM、LM Studio、Ollama)はこの方式を想定しています。"
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "認証情報を api-key ヘッダーで送信します。Azure OpenAI 専用です。他の OpenAI 互換サーバーは Bearer トークンを想定しています。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3032,4 +3032,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "이 대화는 너무 커서 이 기기에서 불러올 수 없습니다."
|
||||
,"hermesSessionNotFound": "이 대화는 더 이상 Hermes 서버에서 사용할 수 없습니다."
|
||||
,"hermesSessionLoadFailedDetail": "이 대화를 불러올 수 없습니다: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "자격 증명을 Authorization: Bearer 헤더로 보냅니다. 대부분의 OpenAI 호환 서버(llama.cpp, vLLM, LM Studio, Ollama)가 이 방식을 사용합니다."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "자격 증명을 api-key 헤더로 보냅니다. Azure OpenAI에만 사용하세요. 다른 OpenAI 호환 서버는 Bearer 토큰을 기대합니다."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3254,4 +3254,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "Dit gesprek is te groot om op dit apparaat te laden."
|
||||
,"hermesSessionNotFound": "Dit gesprek is niet meer beschikbaar op de Hermes-server."
|
||||
,"hermesSessionLoadFailedDetail": "Kan dit gesprek niet laden: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "Verstuurt de inloggegevens in de header Authorization: Bearer. De meeste OpenAI-compatibele servers (llama.cpp, vLLM, LM Studio, Ollama) verwachten dit."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "Verstuurt de inloggegevens in de header api-key. Gebruik dit alleen voor Azure OpenAI; andere OpenAI-compatibele servers verwachten een Bearer-token."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6415,4 +6415,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "Ta rozmowa jest zbyt duża, aby wczytać ją na tym urządzeniu."
|
||||
,"hermesSessionNotFound": "Ta rozmowa nie jest już dostępna na serwerze Hermes."
|
||||
,"hermesSessionLoadFailedDetail": "Nie można wczytać tej rozmowy: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "Wysyła dane logowania w nagłówku Authorization: Bearer. Większość serwerów zgodnych z OpenAI (llama.cpp, vLLM, LM Studio, Ollama) tego oczekuje."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "Wysyła dane logowania w nagłówku api-key. Używaj tego tylko dla Azure OpenAI; inne serwery zgodne z OpenAI oczekują tokenu Bearer."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3368,5 +3368,17 @@
|
|||
"workspaceKnowledgeSubtitle": "Коллекции документов для семантического поиска",
|
||||
"directMcpInsecureCredentialsTitle": "Send credentials without encryption?",
|
||||
"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network.",
|
||||
"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
"directMcpInsecureCredentialsConfirm": "Send without encryption",
|
||||
"hermesSessionTooLarge": "Этот разговор слишком большой, чтобы загрузить его на этом устройстве.",
|
||||
"hermesSessionNotFound": "Этот разговор больше недоступен на сервере Hermes.",
|
||||
"hermesSessionLoadFailedDetail": "Не удалось загрузить этот разговор: {detail}",
|
||||
"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"directBearerDescription": "Отправляет учётные данные в заголовке Authorization: Bearer. Большинство OpenAI-совместимых серверов (llama.cpp, vLLM, LM Studio, Ollama) ожидают именно это.",
|
||||
"directApiKeyHeaderAzureOnlyDescription": "Отправляет учётные данные в заголовке api-key. Используйте только для Azure OpenAI; другие OpenAI-совместимые серверы ожидают токен Bearer."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4909,4 +4909,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "Táto konverzácia je príliš veľká na načítanie na tomto zariadení."
|
||||
,"hermesSessionNotFound": "Táto konverzácia už nie je na serveri Hermes dostupná."
|
||||
,"hermesSessionLoadFailedDetail": "Túto konverzáciu sa nepodarilo načítať: {detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "Odošle prihlasovacie údaje v hlavičke Authorization: Bearer. Väčšina serverov kompatibilných s OpenAI (llama.cpp, vLLM, LM Studio, Ollama) to očakáva."
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "Odošle prihlasovacie údaje v hlavičke api-key. Používajte iba pre Azure OpenAI; ostatné servery kompatibilné s OpenAI očakávajú token Bearer."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3254,4 +3254,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "此对话过大,无法在此设备上加载。"
|
||||
,"hermesSessionNotFound": "此对话在 Hermes 服务器上已不可用。"
|
||||
,"hermesSessionLoadFailedDetail": "无法加载此对话:{detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "通过 Authorization: Bearer 请求头发送凭据。大多数 OpenAI 兼容服务器(llama.cpp、vLLM、LM Studio、Ollama)都采用此方式。"
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "通过 api-key 请求头发送凭据。仅用于 Azure OpenAI;其他 OpenAI 兼容服务器需要 Bearer 令牌。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3254,4 +3254,16 @@
|
|||
,"directMcpInsecureCredentialsTitle": "Send credentials without encryption?"
|
||||
,"directMcpInsecureCredentialsMessage": "This server uses unencrypted HTTP. Your bearer token, OAuth token, and custom headers could be read or changed on the network."
|
||||
,"directMcpInsecureCredentialsConfirm": "Send without encryption"
|
||||
,"hermesSessionTooLarge": "此對話過大,無法在此裝置上載入。"
|
||||
,"hermesSessionNotFound": "此對話在 Hermes 伺服器上已不可用。"
|
||||
,"hermesSessionLoadFailedDetail": "無法載入此對話:{detail}"
|
||||
,"@hermesSessionLoadFailedDetail": {
|
||||
"placeholders": {
|
||||
"detail": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
,"directBearerDescription": "透過 Authorization: Bearer 標頭傳送憑證。大多數 OpenAI 相容伺服器(llama.cpp、vLLM、LM Studio、Ollama)都採用此方式。"
|
||||
,"directApiKeyHeaderAzureOnlyDescription": "透過 api-key 標頭傳送憑證。僅用於 Azure OpenAI;其他 OpenAI 相容伺服器需要 Bearer 權杖。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,14 @@ class ModelListTile extends StatelessWidget {
|
|||
/// Optional row-level action that does not select the model.
|
||||
final Widget? trailing;
|
||||
|
||||
/// The opaque surface this row is painted on.
|
||||
///
|
||||
/// Defaults to the card background because model selectors group rows
|
||||
/// inside a `ConduitCard`. The selected highlight and the trailing overflow
|
||||
/// fade both derive from this color so they match what is actually behind
|
||||
/// the row.
|
||||
final Color? surfaceColor;
|
||||
|
||||
const ModelListTile({
|
||||
super.key,
|
||||
required this.model,
|
||||
|
|
@ -177,6 +185,7 @@ class ModelListTile extends StatelessWidget {
|
|||
this.isPinned = false,
|
||||
this.isLoaded = false,
|
||||
this.trailing,
|
||||
this.surfaceColor,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -185,13 +194,14 @@ class ModelListTile extends StatelessWidget {
|
|||
final l10n = AppLocalizations.of(context)!;
|
||||
final borderRadius = BorderRadius.circular(AppBorderRadius.card);
|
||||
|
||||
final baseBackground = theme.surfaceBackground;
|
||||
final background = isSelected
|
||||
final baseBackground = surfaceColor ?? theme.cardBackground;
|
||||
final rowSurface = isSelected
|
||||
? Color.alphaBlend(
|
||||
theme.buttonPrimary.withValues(alpha: 0.1),
|
||||
baseBackground,
|
||||
)
|
||||
: Colors.transparent;
|
||||
: baseBackground;
|
||||
final background = isSelected ? rowSurface : Colors.transparent;
|
||||
|
||||
final Widget leading;
|
||||
if (isAutoSelect) {
|
||||
|
|
@ -288,6 +298,7 @@ class ModelListTile extends StatelessWidget {
|
|||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 22),
|
||||
child: HorizontalOverflowFade(
|
||||
color: rowSurface,
|
||||
child: HorizontalScrollGestureBoundary(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
|
|
@ -6,8 +9,20 @@ import '../../core/utils/server_version_compat.dart';
|
|||
import '../../features/auth/providers/unified_auth_providers.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../theme/theme_extensions.dart';
|
||||
import 'conduit_components.dart';
|
||||
import 'server_version_warning_controller.dart';
|
||||
|
||||
/// Persistent, in-flow compatibility warning shown with the empty-chat greeting.
|
||||
const serverVersionWarningCardKey = ValueKey<String>(
|
||||
'server-version-warning-card',
|
||||
);
|
||||
const serverVersionWarningCardCloseKey = ValueKey<String>(
|
||||
'server-version-warning-card-close',
|
||||
);
|
||||
|
||||
/// In-flow compatibility warning shown with the empty-chat greeting.
|
||||
///
|
||||
/// Dismissible; the dismissal is remembered per active server and reported
|
||||
/// version, so it returns for a different server or a newer server version.
|
||||
class ServerVersionWarningCard extends ConsumerWidget {
|
||||
const ServerVersionWarningCard({super.key});
|
||||
|
||||
|
|
@ -15,17 +30,57 @@ class ServerVersionWarningCard extends ConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(authNavigationStateProvider);
|
||||
final serverIsNewerThanSupported = ref.watch(serverIncompatibleProvider);
|
||||
final activeServerId = ref.watch(activeServerProvider).asData?.value?.id;
|
||||
final serverVersion = ref
|
||||
.watch(backendConfigProvider)
|
||||
.asData
|
||||
?.value
|
||||
?.version;
|
||||
final dismissedTokens = ref.watch(serverVersionWarningDismissedProvider);
|
||||
|
||||
// serverIncompatibleProvider is only true once the active server id is
|
||||
// known, so the token is always attributable to a real server here.
|
||||
final token = serverVersionWarningToken(
|
||||
serverId: activeServerId ?? '',
|
||||
version: serverVersion,
|
||||
);
|
||||
final showWarning =
|
||||
authState == AuthNavigationState.authenticated &&
|
||||
serverIsNewerThanSupported;
|
||||
if (!showWarning) return const SizedBox.shrink();
|
||||
serverIsNewerThanSupported &&
|
||||
!dismissedTokens.contains(token);
|
||||
|
||||
final motionDuration = context.motionDuration(
|
||||
const Duration(milliseconds: 220),
|
||||
);
|
||||
return AnimatedSwitcher(
|
||||
duration: motionDuration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
child: showWarning
|
||||
? _ServerVersionWarningBody(
|
||||
key: serverVersionWarningCardKey,
|
||||
serverVersion: serverVersion,
|
||||
onDismiss: () => ref
|
||||
.read(serverVersionWarningDismissedProvider.notifier)
|
||||
.dismiss(token),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerVersionWarningBody extends StatelessWidget {
|
||||
const _ServerVersionWarningBody({
|
||||
super.key,
|
||||
required this.serverVersion,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final String? serverVersion;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = context.conduitTheme;
|
||||
final version = serverVersion?.trim();
|
||||
|
|
@ -54,9 +109,13 @@ class ServerVersionWarningCard extends ConsumerWidget {
|
|||
liveRegion: true,
|
||||
label: l10n.serverIncompatibleTitle,
|
||||
child: Container(
|
||||
key: const ValueKey('server-version-warning-card'),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(
|
||||
Spacing.md,
|
||||
Spacing.md,
|
||||
Spacing.sm,
|
||||
Spacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.warningBackground,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
||||
|
|
@ -90,6 +149,17 @@ class ServerVersionWarningCard extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
ConduitIconButton(
|
||||
key: serverVersionWarningCardCloseKey,
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
onPressed: onDismiss,
|
||||
icon: Platform.isIOS
|
||||
? CupertinoIcons.xmark
|
||||
: Icons.close_rounded,
|
||||
iconColor: theme.textSecondary,
|
||||
isCompact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
74
lib/shared/widgets/server_version_warning_controller.dart
Normal file
74
lib/shared/widgets/server_version_warning_controller.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/persistence/persistence_keys.dart';
|
||||
import '../../core/persistence/preferences_store.dart';
|
||||
import '../../core/utils/debug_logger.dart';
|
||||
|
||||
/// Builds the dismissal token for the unsupported-server warning.
|
||||
///
|
||||
/// The token is scoped to the active server *and* its reported version, so a
|
||||
/// dismissed warning reappears only for a different server or after that
|
||||
/// server upgrades to another unsupported version.
|
||||
String serverVersionWarningToken({
|
||||
required String serverId,
|
||||
required String? version,
|
||||
}) => '$serverId|${version?.trim() ?? ''}';
|
||||
|
||||
/// Upper bound on remembered dismissals. Oldest entries drop first, so the
|
||||
/// preference cannot grow without limit across many servers and upgrades.
|
||||
const int kMaxServerVersionWarningDismissals = 32;
|
||||
|
||||
/// The persisted `<serverId>|<version>` tokens the user dismissed.
|
||||
final serverVersionWarningDismissedProvider =
|
||||
NotifierProvider<ServerVersionWarningController, Set<String>>(
|
||||
ServerVersionWarningController.new,
|
||||
);
|
||||
|
||||
class ServerVersionWarningController extends Notifier<Set<String>> {
|
||||
@override
|
||||
Set<String> build() => decodeServerVersionWarningDismissals(
|
||||
PreferencesStore.getString(PreferenceKeys.serverVersionWarningDismissed),
|
||||
);
|
||||
|
||||
/// Remembers [token] alongside every earlier dismissal, so acknowledging
|
||||
/// the warning on one server never re-surfaces it on another.
|
||||
Future<void> dismiss(String token) async {
|
||||
if (state.contains(token)) return;
|
||||
final next = <String>{...state, token};
|
||||
while (next.length > kMaxServerVersionWarningDismissals) {
|
||||
next.remove(next.first);
|
||||
}
|
||||
state = next;
|
||||
try {
|
||||
await PreferencesStore.put(
|
||||
PreferenceKeys.serverVersionWarningDismissed,
|
||||
jsonEncode(next.toList(growable: false)),
|
||||
);
|
||||
} catch (error) {
|
||||
DebugLogger.warning(
|
||||
'Failed to persist server version warning dismissal',
|
||||
scope: 'ui/server-version-warning',
|
||||
data: {'error': error.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the stored dismissal list. A bare token (the first shipped format)
|
||||
/// is accepted as a single entry; anything unparseable yields no dismissals.
|
||||
Set<String> decodeServerVersionWarningDismissals(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return const <String>{};
|
||||
if (!raw.startsWith('[')) return {raw};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return const <String>{};
|
||||
return {
|
||||
for (final entry in decoded)
|
||||
if (entry is String && entry.isNotEmpty) entry,
|
||||
};
|
||||
} on FormatException {
|
||||
return const <String>{};
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import 'package:conduit/core/database/app_database.dart';
|
|||
import 'package:conduit/core/database/database_provider.dart';
|
||||
import 'package:conduit/core/database/database_manager.dart';
|
||||
import 'package:conduit/core/database/mappers/chat_blob_mapper.dart';
|
||||
import 'package:conduit/core/persistence/persistence_keys.dart';
|
||||
import 'package:conduit/core/persistence/preferences_store.dart';
|
||||
import 'package:conduit/core/providers/app_providers.dart';
|
||||
import 'package:conduit/core/services/secure_credential_storage.dart';
|
||||
|
|
@ -129,6 +130,54 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test('a completed clear leaves no incomplete-clear marker', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
authStateManagerProvider.overrideWith(
|
||||
() => _ClearedAuthStateManager(FullAppDataClearOutcome.cleared),
|
||||
),
|
||||
directConnectionProfilesProvider.overrideWith(_EmptyDirectProfiles.new),
|
||||
hermesConfigProvider.overrideWith(_EmptyHermesConfig.new),
|
||||
directLocalDatabasePurgeProvider.overrideWithValue(() async {}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(authStateManagerProvider.future);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
container.read(hermesConfigProvider);
|
||||
|
||||
await container
|
||||
.read(signOutCoordinatorProvider)
|
||||
.signOut(keepServerDetails: true);
|
||||
|
||||
check(PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear))
|
||||
.isNull();
|
||||
});
|
||||
|
||||
test('an incomplete clear leaves the restart marker armed', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
authStateManagerProvider.overrideWith(
|
||||
() => _ClearedAuthStateManager(FullAppDataClearOutcome.incomplete),
|
||||
),
|
||||
directConnectionProfilesProvider.overrideWith(_EmptyDirectProfiles.new),
|
||||
hermesConfigProvider.overrideWith(_EmptyHermesConfig.new),
|
||||
directLocalDatabasePurgeProvider.overrideWithValue(() async {}),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(authStateManagerProvider.future);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
container.read(hermesConfigProvider);
|
||||
|
||||
await container
|
||||
.read(signOutCoordinatorProvider)
|
||||
.signOut(keepServerDetails: true);
|
||||
|
||||
check(PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear))
|
||||
.equals(true);
|
||||
});
|
||||
|
||||
test(
|
||||
'failed direct-local purge keeps destructive-clear barriers closed',
|
||||
() async {
|
||||
|
|
|
|||
|
|
@ -818,6 +818,32 @@ void main() {
|
|||
// Payload builder
|
||||
// -----------------------------------------------------------------------
|
||||
group('buildChatCompletionPayloadForTest', () {
|
||||
test('explicit reasoning effort overrides user settings params', () {
|
||||
final api = _buildApiServiceForTest(_FakeAdapter.json({}));
|
||||
|
||||
Map<String, dynamic> params(String? reasoningEffort) =>
|
||||
api.buildChatCompletionPayloadForTest(
|
||||
messages: [
|
||||
{'role': 'user', 'content': 'hello'},
|
||||
],
|
||||
model: 'gpt-5',
|
||||
messageId: 'msg-1',
|
||||
sessionId: 'sess-1',
|
||||
modelItem: {'id': 'gpt-5', 'name': 'gpt-5'},
|
||||
userSettings: {
|
||||
'params': {'reasoning_effort': 'low', 'temperature': 0.2},
|
||||
},
|
||||
reasoningEffort: reasoningEffort,
|
||||
)['params']
|
||||
as Map<String, dynamic>;
|
||||
|
||||
check(params('high'))
|
||||
.deepEquals({'reasoning_effort': 'high', 'temperature': 0.2});
|
||||
check(params(null))
|
||||
.deepEquals({'reasoning_effort': 'low', 'temperature': 0.2});
|
||||
check(params('automatic')).deepEquals({'temperature': 0.2});
|
||||
});
|
||||
|
||||
test('preserves OpenWebUI request shape', () {
|
||||
final api = _buildApiServiceForTest(_FakeAdapter.json({}));
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,20 @@ void main() {
|
|||
check(hydrated.value).equals('vendor_ultra');
|
||||
});
|
||||
|
||||
test('late effort hydration keeps the user pick over the server value', () {
|
||||
final hydrated = nativeHydratedServerReasoningEffort(
|
||||
model: const Model(
|
||||
id: 'workspace-reasoning-model',
|
||||
name: 'Workspace reasoning model',
|
||||
),
|
||||
detail: const ServerModelReasoningEffort.known('vendor_ultra'),
|
||||
personalizationEffort: 'low',
|
||||
localEffort: 'high',
|
||||
);
|
||||
|
||||
check(hydrated.value).equals('high');
|
||||
});
|
||||
|
||||
test('late selector hydration cannot update a newer presentation', () {
|
||||
final generations = NativeSheetHydrationGeneration();
|
||||
final first = generations.begin();
|
||||
|
|
|
|||
|
|
@ -572,8 +572,10 @@ class _MockSocketService implements SocketService {
|
|||
handlerId: 'test-ch',
|
||||
);
|
||||
|
||||
final StreamController<void> reconnects = StreamController<void>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<void> get onReconnect => const Stream.empty();
|
||||
Stream<void> get onReconnect => reconnects.stream;
|
||||
|
||||
@override
|
||||
bool get isConnected => true;
|
||||
|
|
@ -1673,6 +1675,202 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'taskSocket response.incomplete keeps the answer without an error',
|
||||
() async {
|
||||
// Upstream passes the provider's response.incomplete through as a
|
||||
// response:completion event but treats it as a no-op: the turn still
|
||||
// ends with a normal done. Raising an error here flashed a banner
|
||||
// under the answer until that done cleared it.
|
||||
final log = _CallbackLog();
|
||||
final registrar = FakeSocketInjector();
|
||||
|
||||
_attach(
|
||||
session: ChatCompletionSession.taskSocket(
|
||||
messageId: 'msg-1',
|
||||
sessionId: 'sess-1',
|
||||
taskId: 'task-1',
|
||||
),
|
||||
log: log,
|
||||
socketService: _MockSocketService(registrar),
|
||||
);
|
||||
await pumpMicrotasks();
|
||||
|
||||
registrar.emitChatEvent('response:completion', {
|
||||
'type': 'response.output_text.delta',
|
||||
'item_id': 'msg1',
|
||||
'output_index': 0,
|
||||
'content_index': 0,
|
||||
'delta': 'Hello',
|
||||
}, messageId: 'msg-1');
|
||||
await pumpMicrotasks();
|
||||
|
||||
registrar.emitChatEvent('response:completion', {
|
||||
'type': 'response.incomplete',
|
||||
'response': {
|
||||
'id': 'resp-1',
|
||||
'output': [
|
||||
{
|
||||
'type': 'message',
|
||||
'id': 'msg1',
|
||||
'status': 'incomplete',
|
||||
'content': [
|
||||
{'type': 'output_text', 'text': 'Hello'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}, messageId: 'msg-1');
|
||||
await pumpMicrotasks();
|
||||
|
||||
check(log.messages.last.error).isNull();
|
||||
check(log.messages.last.content).endsWith('Hello');
|
||||
|
||||
registrar.emitChatEvent('chat:completion', {
|
||||
'done': true,
|
||||
'output': [
|
||||
{
|
||||
'type': 'message',
|
||||
'id': 'msg1',
|
||||
'status': 'completed',
|
||||
'content': [
|
||||
{'type': 'output_text', 'text': 'Hello'},
|
||||
],
|
||||
},
|
||||
],
|
||||
}, messageId: 'msg-1');
|
||||
await pumpMicrotasks();
|
||||
|
||||
check(log.messages.every((message) => message.error == null)).isTrue();
|
||||
check(log.finishCount).equals(1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'taskSocket response.failed still surfaces the provider error',
|
||||
() async {
|
||||
final log = _CallbackLog();
|
||||
final registrar = FakeSocketInjector();
|
||||
|
||||
_attach(
|
||||
session: ChatCompletionSession.taskSocket(
|
||||
messageId: 'msg-1',
|
||||
sessionId: 'sess-1',
|
||||
taskId: 'task-1',
|
||||
),
|
||||
log: log,
|
||||
socketService: _MockSocketService(registrar),
|
||||
);
|
||||
await pumpMicrotasks();
|
||||
|
||||
registrar.emitChatEvent('response:completion', {
|
||||
'type': 'response.failed',
|
||||
'response': {
|
||||
'id': 'resp-1',
|
||||
'error': {'message': 'Provider rejected the request.'},
|
||||
},
|
||||
}, messageId: 'msg-1');
|
||||
await pumpMicrotasks();
|
||||
|
||||
check(log.messages.last.error?.content)
|
||||
.equals('Provider rejected the request.');
|
||||
},
|
||||
);
|
||||
|
||||
test('reconnect recovery does not finish a live stream from a mid-write snapshot', () async {
|
||||
// Returning to the foreground reconnects the socket, which polls the
|
||||
// server. Open WebUI persists the in-flight assistant with
|
||||
// `done: false` and no `isStreaming` key, so the poll must not treat
|
||||
// the missing flag as completion: the stream is still running.
|
||||
final log = _CallbackLog(
|
||||
initialMessages: fakeStreamingAssistantMessages(content: 'Hel'),
|
||||
);
|
||||
final registrar = FakeSocketInjector();
|
||||
final socket = _MockSocketService(registrar);
|
||||
final api = _buildFakeApi(
|
||||
pollResponse: _serverConversationResponse(
|
||||
messages: [
|
||||
_serverAssistantMessage(content: 'Hello wor', done: false),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
_attach(
|
||||
session: ChatCompletionSession.taskSocket(
|
||||
messageId: 'msg-1',
|
||||
sessionId: 'sess-1',
|
||||
taskId: 'task-1',
|
||||
),
|
||||
log: log,
|
||||
api: api,
|
||||
socketService: socket,
|
||||
);
|
||||
await pumpMicrotasks();
|
||||
|
||||
socket.reconnects.add(null);
|
||||
// The recovery poll adopts the longer server body; that adoption is the
|
||||
// observable proof the reconnect recovery ran to completion.
|
||||
await waitForCondition(
|
||||
() => log.messages.last.content.endsWith('Hello wor'),
|
||||
timeout: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
check(log.finishCount).equals(0);
|
||||
check(log.messages.last.isStreaming).isTrue();
|
||||
|
||||
// The live stream then completes normally.
|
||||
registrar.emitChatEvent('chat:completion', {
|
||||
'choices': [
|
||||
{
|
||||
'delta': {'content': 'lo'},
|
||||
},
|
||||
],
|
||||
}, messageId: 'msg-1');
|
||||
registrar.emitChatEvent('chat:completion', {
|
||||
'done': true,
|
||||
'content': 'Hello',
|
||||
}, messageId: 'msg-1');
|
||||
await pumpMicrotasks();
|
||||
check(log.finishCount).equals(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'reconnect recovery still finishes on an explicit server done',
|
||||
() async {
|
||||
final log = _CallbackLog(
|
||||
initialMessages: fakeStreamingAssistantMessages(content: 'Hel'),
|
||||
);
|
||||
final registrar = FakeSocketInjector();
|
||||
final socket = _MockSocketService(registrar);
|
||||
final api = _buildFakeApi(
|
||||
pollResponse: _serverConversationResponse(
|
||||
messages: [_serverAssistantMessage(content: 'Hello', done: true)],
|
||||
),
|
||||
);
|
||||
|
||||
_attach(
|
||||
session: ChatCompletionSession.taskSocket(
|
||||
messageId: 'msg-1',
|
||||
sessionId: 'sess-1',
|
||||
taskId: 'task-1',
|
||||
),
|
||||
log: log,
|
||||
api: api,
|
||||
socketService: socket,
|
||||
);
|
||||
await pumpMicrotasks();
|
||||
|
||||
socket.reconnects.add(null);
|
||||
await waitForCondition(
|
||||
() => log.finishCount == 1,
|
||||
timeout: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
check(log.finishCount).equals(1);
|
||||
check(log.messages.last.content).endsWith('Hello');
|
||||
},
|
||||
);
|
||||
|
||||
test('taskSocket plain snapshots do not clear reasoning details', () async {
|
||||
final log = _CallbackLog();
|
||||
final registrar = FakeSocketInjector();
|
||||
|
|
|
|||
165
test/core/utils/persisted_message_content_test.dart
Normal file
165
test/core/utils/persisted_message_content_test.dart
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import 'package:checks/checks.dart';
|
||||
import 'package:conduit/core/models/chat_message.dart';
|
||||
import 'package:conduit/core/services/direct_replay_output.dart';
|
||||
import 'package:conduit/core/utils/persisted_message_content.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const rendered =
|
||||
'<details type="reasoning" done="true" duration="1">\n'
|
||||
'<summary>Thought for 1 second</summary>\n> plan\n</details>\n'
|
||||
'<details type="tool_calls" done="true" id="c1" name="lookup" '
|
||||
'arguments="{}" result=""42"">\n'
|
||||
'<summary>Tool Executed</summary>\n</details>\n'
|
||||
'The answer is 42.';
|
||||
|
||||
Map<String, dynamic> messageItem(List<Map<String, dynamic>> parts) =>
|
||||
<String, dynamic>{
|
||||
'type': 'message',
|
||||
'id': 'm',
|
||||
'role': 'assistant',
|
||||
'status': 'completed',
|
||||
'content': parts,
|
||||
};
|
||||
|
||||
const toolItems = <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'type': 'reasoning',
|
||||
'id': 'r1',
|
||||
'summary': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'summary_text', 'text': 'plan'},
|
||||
],
|
||||
},
|
||||
<String, dynamic>{
|
||||
'type': 'function_call',
|
||||
'id': 'c1',
|
||||
'call_id': 'c1',
|
||||
'name': 'lookup',
|
||||
'arguments': '{}',
|
||||
'status': 'completed',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'type': 'function_call_output',
|
||||
'call_id': 'c1',
|
||||
'output': '"42"',
|
||||
},
|
||||
];
|
||||
|
||||
ChatMessage assistant(String content, {List<Map<String, dynamic>>? output}) =>
|
||||
ChatMessage(
|
||||
id: 'a',
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
output: output,
|
||||
);
|
||||
|
||||
group('outputItemsMessageText', () {
|
||||
test('mirrors getOutputText: message items only, parts concatenated, '
|
||||
'items joined by newline, blank items skipped', () {
|
||||
final text = outputItemsMessageText(<Map<String, dynamic>>[
|
||||
...toolItems,
|
||||
messageItem([
|
||||
{'type': 'output_text', 'text': 'Hello '},
|
||||
{'type': 'output_text', 'text': 'world'},
|
||||
]),
|
||||
messageItem([
|
||||
{'type': 'output_text', 'text': ' '},
|
||||
]),
|
||||
messageItem([
|
||||
{'type': 'output_text', 'text': 'Again'},
|
||||
{'type': 'refusal', 'refusal': 'no text key'},
|
||||
]),
|
||||
]);
|
||||
|
||||
check(text).equals('Hello world\nAgain');
|
||||
});
|
||||
|
||||
test('stringifies non-string text parts and ignores null text', () {
|
||||
final text = outputItemsMessageText(<Map<String, dynamic>>[
|
||||
messageItem([
|
||||
{'type': 'output_text', 'text': 12},
|
||||
{'type': 'output_text', 'text': null},
|
||||
]),
|
||||
]);
|
||||
|
||||
check(text).equals('12');
|
||||
});
|
||||
|
||||
test('returns an empty string for string content or no message items', () {
|
||||
check(outputItemsMessageText(const <Map<String, dynamic>>[])).equals('');
|
||||
check(outputItemsMessageText(toolItems)).equals('');
|
||||
check(
|
||||
outputItemsMessageText(<Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'message', 'content': 'plain'},
|
||||
]),
|
||||
).equals('');
|
||||
});
|
||||
});
|
||||
|
||||
group('persistedMessageContent', () {
|
||||
test('returns the output message text when output carries the turn', () {
|
||||
final message = assistant(
|
||||
rendered,
|
||||
output: [
|
||||
...toolItems,
|
||||
messageItem([
|
||||
{'type': 'output_text', 'text': 'The answer is 42.'},
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
check(persistedMessageContent(message)).equals('The answer is 42.');
|
||||
});
|
||||
|
||||
test('keeps content untouched without output', () {
|
||||
check(persistedMessageContent(assistant(rendered))).equals(rendered);
|
||||
check(persistedMessageContent(assistant(rendered, output: const [])))
|
||||
.equals(rendered);
|
||||
});
|
||||
|
||||
test('keeps non-assistant content untouched', () {
|
||||
final user = ChatMessage(
|
||||
id: 'u',
|
||||
role: 'user',
|
||||
content: rendered,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
output: [
|
||||
messageItem([
|
||||
{'type': 'output_text', 'text': 'other'},
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
check(persistedMessageContent(user)).equals(rendered);
|
||||
});
|
||||
|
||||
test('falls back to stripped prose when output has no message text', () {
|
||||
final message = assistant(rendered, output: toolItems);
|
||||
|
||||
check(persistedMessageContent(message)).equals('The answer is 42.');
|
||||
});
|
||||
|
||||
test('keeps content when neither output nor prose is available', () {
|
||||
const detailsOnly =
|
||||
'<details type="tool_calls" done="true" id="c1" name="lookup" '
|
||||
'arguments="{}" result="1">\n<summary>Tool Executed</summary>\n'
|
||||
'</details>';
|
||||
final message = assistant(detailsOnly, output: toolItems);
|
||||
|
||||
check(persistedMessageContent(message)).equals(detailsOnly);
|
||||
});
|
||||
|
||||
test('keeps rendered content for a direct replay mirror', () {
|
||||
final message = assistant(
|
||||
rendered,
|
||||
output: buildConduitDirectReplayOutput(
|
||||
assistantMessageId: 'a',
|
||||
rawContent: 'The answer is 42.',
|
||||
),
|
||||
);
|
||||
|
||||
check(persistedMessageContent(message)).equals(rendered);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -191,6 +191,7 @@ class _GatedCompletionApi extends ApiService {
|
|||
String? responseMessageId,
|
||||
Map<String, dynamic>? userSettings,
|
||||
String? parentId,
|
||||
String? reasoningEffort,
|
||||
Map<String, dynamic>? userMessage,
|
||||
Map<String, dynamic>? variables,
|
||||
List<Map<String, dynamic>>? files,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ class _RecordingCompletionApi extends ApiService {
|
|||
String? responseMessageId,
|
||||
Map<String, dynamic>? userSettings,
|
||||
String? parentId,
|
||||
String? reasoningEffort,
|
||||
Map<String, dynamic>? userMessage,
|
||||
Map<String, dynamic>? variables,
|
||||
List<Map<String, dynamic>>? files,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:checks/checks.dart';
|
||||
import 'package:conduit/core/models/chat_message.dart';
|
||||
import 'package:conduit/core/services/direct_replay_output.dart';
|
||||
import 'package:conduit/features/chat/providers/chat_providers.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
|
|
@ -97,5 +98,132 @@ void main() {
|
|||
.equals('1');
|
||||
check(payload.containsKey('codeExecutions')).isFalse();
|
||||
});
|
||||
|
||||
group('persisted content (issue #703)', () {
|
||||
const renderedDetails =
|
||||
'<details type="tool_calls" done="true" id="call-1" '
|
||||
'name="get_weather" arguments="{"city":"Oslo"}" '
|
||||
'result=""Sunny"">\n'
|
||||
'<summary>Tool Executed</summary>\n</details>\n';
|
||||
const structuredOutput = <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'type': 'function_call',
|
||||
'id': 'call-1',
|
||||
'call_id': 'call-1',
|
||||
'name': 'get_weather',
|
||||
'arguments': '{"city":"Oslo"}',
|
||||
'status': 'completed',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'type': 'function_call_output',
|
||||
'call_id': 'call-1',
|
||||
'output': '"Sunny"',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'type': 'message',
|
||||
'id': 'msg-1',
|
||||
'role': 'assistant',
|
||||
'status': 'completed',
|
||||
'content': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'type': 'output_text', 'text': 'It is sunny.'},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
ChatMessage assistant({
|
||||
required String content,
|
||||
List<Map<String, dynamic>>? output,
|
||||
}) => ChatMessage(
|
||||
id: 'assistant-2',
|
||||
role: 'assistant',
|
||||
content: content,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000),
|
||||
model: 'gpt-4o',
|
||||
isStreaming: false,
|
||||
output: output,
|
||||
);
|
||||
|
||||
test('stores the output message text, not the rendered details', () {
|
||||
// The OWUI web client persists `content = getOutputText(output)` and
|
||||
// rebuilds the <details> presentation from `output` on load; pushing
|
||||
// the rendered markup leaks it into shares and exports.
|
||||
final row = localEchoRowForMessage(
|
||||
'chat-1',
|
||||
assistant(
|
||||
content: '${renderedDetails}It is sunny.',
|
||||
output: structuredOutput,
|
||||
),
|
||||
);
|
||||
|
||||
check(row.payload['content']).equals('It is sunny.');
|
||||
check(row.content).equals('It is sunny.');
|
||||
check(row.payload['output'])
|
||||
.isA<List<Object?>>()
|
||||
.deepEquals(structuredOutput);
|
||||
});
|
||||
|
||||
test('keeps content byte-for-byte when the message has no output', () {
|
||||
const content = '${renderedDetails}It is sunny.';
|
||||
final row = localEchoRowForMessage(
|
||||
'chat-1',
|
||||
assistant(content: content, output: null),
|
||||
);
|
||||
|
||||
check(row.payload['content']).equals(content);
|
||||
check(row.content).equals(content);
|
||||
check(row.payload.containsKey('output')).isFalse();
|
||||
});
|
||||
|
||||
test('leaves user messages untouched', () {
|
||||
const content =
|
||||
'<details type="tool_calls"><summary>x</summary>'
|
||||
'</details>\nPlease keep this literally.';
|
||||
final user = ChatMessage(
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: content,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000000000),
|
||||
output: structuredOutput,
|
||||
);
|
||||
|
||||
check(localEchoRowForMessage('chat-1', user).payload['content'])
|
||||
.equals(content);
|
||||
});
|
||||
|
||||
test('direct payload stores the output message text too', () {
|
||||
final payload = directPersistedMessagePayloadForTest(
|
||||
assistant(
|
||||
content: '${renderedDetails}It is sunny.',
|
||||
output: structuredOutput,
|
||||
),
|
||||
);
|
||||
|
||||
check(payload['content']).equals('It is sunny.');
|
||||
check(payload['output'])
|
||||
.isA<List<Object?>>()
|
||||
.deepEquals(structuredOutput);
|
||||
});
|
||||
|
||||
test('direct payload keeps the rendered content for a replay mirror', () {
|
||||
// On load the direct replay mirror short-circuits the structured
|
||||
// output re-synthesis, so the rendered content is the only copy of
|
||||
// the reasoning/tool presentation the UI can show.
|
||||
const content =
|
||||
'<details type="reasoning" done="true" duration="2">\n'
|
||||
'<summary>Thought for 2 seconds</summary>\n> hmm\n</details>\n'
|
||||
'It is sunny.';
|
||||
final payload = directPersistedMessagePayloadForTest(
|
||||
assistant(
|
||||
content: content,
|
||||
output: buildConduitDirectReplayOutput(
|
||||
assistantMessageId: 'assistant-2',
|
||||
rawContent: 'It is sunny.',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
check(payload['content']).equals(content);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,7 +170,16 @@ void main() {
|
|||
check(container.read(selectedModelProvider)).identicalTo(models.first);
|
||||
});
|
||||
|
||||
test('server model reasoning effort takes precedence over user effort', () {
|
||||
ApiService buildApi(String serverId) => ApiService(
|
||||
serverConfig: ServerConfig(
|
||||
id: serverId,
|
||||
name: 'Server $serverId',
|
||||
url: 'https://$serverId.example.test',
|
||||
),
|
||||
workerManager: WorkerManager(),
|
||||
);
|
||||
|
||||
test('server model default beats the global user effort without a pick', () {
|
||||
const model = Model(
|
||||
id: 'server-model',
|
||||
name: 'Server model',
|
||||
|
|
@ -180,14 +189,114 @@ void main() {
|
|||
},
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
apiServiceProvider.overrideWithValue(buildApi('server-a')),
|
||||
personalizationSettingsProvider.overrideWith(
|
||||
() => _FixedPersonalizationSettings(
|
||||
const ServerUserSettings(reasoningEffort: 'low'),
|
||||
),
|
||||
),
|
||||
selectedModelProvider.overrideWith(() => _FixedSelectedModel(model)),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container.read(selectedModelProvider.notifier).set(model);
|
||||
|
||||
check(container.read(reasoningEffortProvider)).equals('none');
|
||||
check(reasoningEffortForModel(container.read, model)).equals('none');
|
||||
});
|
||||
|
||||
test('user pick overrides the server model default per model', () async {
|
||||
const model = Model(
|
||||
id: 'server-model',
|
||||
name: 'Server model',
|
||||
metadata: {
|
||||
'params': {'reasoning_effort': 'medium'},
|
||||
},
|
||||
);
|
||||
const sibling = Model(
|
||||
id: 'sibling-model',
|
||||
name: 'Sibling model',
|
||||
metadata: {
|
||||
'params': {'reasoning_effort': 'medium'},
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
apiServiceProvider.overrideWithValue(buildApi('server-a')),
|
||||
personalizationSettingsProvider.overrideWith(
|
||||
() => _FixedPersonalizationSettings(
|
||||
const ServerUserSettings(reasoningEffort: 'low'),
|
||||
),
|
||||
),
|
||||
selectedModelProvider.overrideWith(() => _FixedSelectedModel(model)),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
const key = 'openwebui:server-a:server-model';
|
||||
|
||||
check(localReasoningEffortKeyForModel(container.read, model)).equals(key);
|
||||
check(container.read(reasoningEffortProvider)).equals('medium');
|
||||
|
||||
await setReasoningEffortForModel(container.read, model, 'high');
|
||||
check(container.read(localReasoningEffortsProvider)[key]).equals('high');
|
||||
check(container.read(reasoningEffortProvider)).equals('high');
|
||||
check(reasoningEffortForModel(container.read, model)).equals('high');
|
||||
check(reasoningEffortForModel(container.read, sibling)).equals('medium');
|
||||
check(
|
||||
jsonDecode(
|
||||
PreferencesStore.getString(PreferenceKeys.reasoningEffortByModel)!,
|
||||
),
|
||||
).isA<Map>().deepEquals({key: 'high'});
|
||||
|
||||
// An explicit "automatic" pick is remembered and overrides the model
|
||||
// default back to "let the server decide".
|
||||
await setReasoningEffortForModel(container.read, model, 'automatic');
|
||||
check(container.read(localReasoningEffortsProvider)[key])
|
||||
.equals('automatic');
|
||||
check(container.read(reasoningEffortProvider)).equals('automatic');
|
||||
check(reasoningEffortForModel(container.read, model)).equals('automatic');
|
||||
|
||||
// Clearing the pick restores the model default.
|
||||
await container.read(localReasoningEffortsProvider.notifier).set(key, null);
|
||||
check(container.read(reasoningEffortProvider)).equals('medium');
|
||||
check(reasoningEffortForModel(container.read, model)).equals('medium');
|
||||
});
|
||||
|
||||
test('server model picks are namespaced by server id', () async {
|
||||
const model = Model(id: 'gpt-5', name: 'GPT-5');
|
||||
final serverA = ProviderContainer(
|
||||
overrides: [apiServiceProvider.overrideWithValue(buildApi('server-a'))],
|
||||
);
|
||||
addTearDown(serverA.dispose);
|
||||
final serverB = ProviderContainer(
|
||||
overrides: [apiServiceProvider.overrideWithValue(buildApi('server-b'))],
|
||||
);
|
||||
addTearDown(serverB.dispose);
|
||||
|
||||
await setReasoningEffortForModel(serverA.read, model, 'high');
|
||||
|
||||
check(localReasoningEffortKeyForModel(serverA.read, model))
|
||||
.equals('openwebui:server-a:gpt-5');
|
||||
check(localReasoningEffortKeyForModel(serverB.read, model))
|
||||
.equals('openwebui:server-b:gpt-5');
|
||||
check(reasoningEffortForModel(serverA.read, model)).equals('high');
|
||||
check(reasoningEffortForModel(serverB.read, model)).equals('automatic');
|
||||
check(localReasoningEffortForModel(serverB.read, model)).isNull();
|
||||
});
|
||||
|
||||
test('server model pick is not stored without an active server', () async {
|
||||
const model = Model(id: 'gpt-5', name: 'GPT-5');
|
||||
final container = ProviderContainer(
|
||||
overrides: [apiServiceProvider.overrideWithValue(null)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
check(localReasoningEffortKeyForModel(container.read, model)).isNull();
|
||||
await setReasoningEffortForModel(container.read, model, 'high');
|
||||
check(container.read(localReasoningEffortsProvider)).isEmpty();
|
||||
});
|
||||
|
||||
test('server model preserves custom reasoning effort values', () {
|
||||
const model = Model(
|
||||
id: 'custom-server-model',
|
||||
|
|
@ -197,7 +306,9 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
final container = ProviderContainer();
|
||||
final container = ProviderContainer(
|
||||
overrides: [apiServiceProvider.overrideWithValue(buildApi('server-a'))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
container.read(selectedModelProvider.notifier).set(model);
|
||||
|
||||
|
|
@ -419,6 +530,54 @@ void main() {
|
|||
check(supportedParams['reasoning_effort']).equals('medium');
|
||||
});
|
||||
|
||||
test('chat payload sends the per-model pick over the user default', () {
|
||||
final api = ApiService(
|
||||
serverConfig: const ServerConfig(
|
||||
id: 'reasoning-test',
|
||||
name: 'Reasoning test',
|
||||
url: 'https://example.test',
|
||||
),
|
||||
workerManager: WorkerManager(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> params(
|
||||
String model,
|
||||
String? effort, {
|
||||
bool configured = true,
|
||||
}) =>
|
||||
api.buildChatCompletionPayloadForTest(
|
||||
messages: const <Map<String, dynamic>>[
|
||||
<String, dynamic>{'role': 'user', 'content': 'Hello'},
|
||||
],
|
||||
model: model,
|
||||
messageId: 'message-id',
|
||||
sessionId: 'session-id',
|
||||
modelItem: <String, dynamic>{
|
||||
'id': model,
|
||||
'name': model,
|
||||
if (configured)
|
||||
'params': <String, dynamic>{'reasoning_effort': 'medium'},
|
||||
},
|
||||
userSettings: <String, dynamic>{
|
||||
'params': <String, dynamic>{'reasoning_effort': 'low'},
|
||||
},
|
||||
reasoningEffort: effort,
|
||||
)['params']
|
||||
as Map<String, dynamic>;
|
||||
|
||||
check(params('gpt-5', 'high')['reasoning_effort']).equals('high');
|
||||
check(params('gpt-5', null)['reasoning_effort']).equals('low');
|
||||
check(params('gpt-5', 'automatic').containsKey('reasoning_effort'))
|
||||
.isFalse();
|
||||
check(
|
||||
params(
|
||||
'gpt-4o',
|
||||
'high',
|
||||
configured: false,
|
||||
).containsKey('reasoning_effort'),
|
||||
).isFalse();
|
||||
});
|
||||
|
||||
test('custom OpenWebUI model metadata retains supported effort', () {
|
||||
final api = ApiService(
|
||||
serverConfig: const ServerConfig(
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ class _RecordingCompletionApi extends ApiService {
|
|||
String? responseMessageId,
|
||||
Map<String, dynamic>? userSettings,
|
||||
String? parentId,
|
||||
String? reasoningEffort,
|
||||
Map<String, dynamic>? userMessage,
|
||||
Map<String, dynamic>? variables,
|
||||
List<Map<String, dynamic>>? files,
|
||||
|
|
|
|||
|
|
@ -342,7 +342,11 @@ void main() {
|
|||
check(config.audioSource).equals(AndroidAudioSource.voiceCommunication);
|
||||
check(config.audioManagerMode)
|
||||
.equals(AudioManagerMode.modeInCommunication);
|
||||
check(config.manageBluetooth).isTrue();
|
||||
// The coordinator owns SCO/communication-device routing during calls.
|
||||
// record_android's Bluetooth manager clears the communication device on
|
||||
// every recorder stop, which knocked device TTS back to the earpiece
|
||||
// (issue #716), so the plugin must not manage Bluetooth here.
|
||||
check(config.manageBluetooth).isFalse();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:checks/checks.dart';
|
||||
import 'package:conduit/features/chat/voice_mode/chat_voice_audio_session_coordinator.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -292,6 +293,142 @@ void main() {
|
|||
).deepEquals(<bool>[true]);
|
||||
});
|
||||
});
|
||||
group('ChatVoiceAudioSessionCoordinator Android loudspeaker route', () {
|
||||
late ChatVoiceAudioSessionCoordinator coordinator;
|
||||
late _FakeAndroidAudioManagerChannel audioManager;
|
||||
|
||||
setUp(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
audioManager = _FakeAndroidAudioManagerChannel()..install();
|
||||
addTearDown(audioManager.uninstall);
|
||||
coordinator = ChatVoiceAudioSessionCoordinator()
|
||||
..debugTreatAsAndroid = true;
|
||||
addTearDown(coordinator.dispose);
|
||||
});
|
||||
|
||||
test(
|
||||
'reports a refused move when the read-back is not the speaker',
|
||||
() async {
|
||||
// The platform says yes to setCommunicationDevice and takes the legacy
|
||||
// setSpeakerphoneOn call without complaint, but neither moves the route
|
||||
// (issue #716). Only the read-back can tell, and it says earpiece.
|
||||
audioManager.honourLoudspeaker = false;
|
||||
|
||||
check(await coordinator.setSpeakerphoneEnabled(true)).isFalse();
|
||||
// The rejected selection is released before the legacy fallback, so
|
||||
// the system is not left holding a device the read-back disowned.
|
||||
check(audioManager.communicationDeviceId).isNull();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'reports an applied move once the read-back confirms the speaker',
|
||||
() async {
|
||||
check(await coordinator.setSpeakerphoneEnabled(true)).isTrue();
|
||||
check(audioManager.communicationDeviceId)
|
||||
.isNotNull()
|
||||
.equals(_FakeAndroidAudioManagerChannel.speakerId);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'speaking passes re-select the speaker after it was cleared',
|
||||
() async {
|
||||
check(await coordinator.setSpeakerphoneEnabled(true)).isTrue();
|
||||
|
||||
// Something else (the recorder plugin's Bluetooth manager, the system)
|
||||
// cleared the communication device between listening and speaking.
|
||||
audioManager.communicationDeviceId = null;
|
||||
await coordinator.configureForSpeaking();
|
||||
check(audioManager.communicationDeviceId)
|
||||
.isNotNull()
|
||||
.equals(_FakeAndroidAudioManagerChannel.speakerId);
|
||||
|
||||
audioManager.communicationDeviceId = null;
|
||||
await coordinator.configureForBargeInSpeaking();
|
||||
check(audioManager.communicationDeviceId)
|
||||
.isNotNull()
|
||||
.equals(_FakeAndroidAudioManagerChannel.speakerId);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Stands in for audio_session's Android audio manager on a test host.
|
||||
///
|
||||
/// Keeps just enough state to answer the route calls the coordinator makes and
|
||||
/// to read the route back: which communication device is selected, and whether
|
||||
/// legacy speakerphone is on.
|
||||
class _FakeAndroidAudioManagerChannel {
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
'com.ryanheise.android_audio_manager',
|
||||
);
|
||||
static const int earpieceId = 1;
|
||||
static const int speakerId = 2;
|
||||
|
||||
/// When false, the platform accepts every loudspeaker request but leaves the
|
||||
/// call on the earpiece, which is what the report in issue #716 describes.
|
||||
bool honourLoudspeaker = true;
|
||||
int? communicationDeviceId;
|
||||
bool speakerphoneOn = false;
|
||||
|
||||
void install() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_channel, _handle);
|
||||
}
|
||||
|
||||
void uninstall() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_channel, null);
|
||||
}
|
||||
|
||||
Future<Object?> _handle(MethodCall call) async {
|
||||
final args = call.arguments as List<dynamic>? ?? const <dynamic>[];
|
||||
switch (call.method) {
|
||||
case 'getMode':
|
||||
return 0;
|
||||
case 'isSpeakerphoneOn':
|
||||
return speakerphoneOn;
|
||||
case 'setSpeakerphoneOn':
|
||||
final enabled = args[0] as bool;
|
||||
if (!enabled || honourLoudspeaker) {
|
||||
speakerphoneOn = enabled;
|
||||
}
|
||||
return null;
|
||||
case 'getAvailableCommunicationDevices':
|
||||
return [_device(earpieceId, 1), _device(speakerId, 2)];
|
||||
case 'setCommunicationDevice':
|
||||
final id = args[0] as int;
|
||||
communicationDeviceId = id == speakerId && !honourLoudspeaker
|
||||
? earpieceId
|
||||
: id;
|
||||
return true;
|
||||
case 'getCommunicationDevice':
|
||||
final id = communicationDeviceId;
|
||||
if (id == null) return null;
|
||||
return _device(id, id == speakerId ? 2 : 1);
|
||||
case 'clearCommunicationDevice':
|
||||
communicationDeviceId = null;
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [type] is the [AndroidAudioDeviceType] index: 1 earpiece, 2 speaker.
|
||||
Map<String, Object?> _device(int id, int type) => <String, Object?>{
|
||||
'id': id,
|
||||
'productName': 'device-$id',
|
||||
'address': null,
|
||||
'isSource': false,
|
||||
'isSink': true,
|
||||
'sampleRates': <int>[],
|
||||
'channelMasks': <int>[],
|
||||
'channelIndexMasks': <int>[],
|
||||
'channelCounts': <int>[],
|
||||
'encodings': <int>[],
|
||||
'type': type,
|
||||
};
|
||||
}
|
||||
|
||||
AudioDevice _outputDevice(AudioDeviceType type) => AudioDevice(
|
||||
|
|
|
|||
|
|
@ -2111,6 +2111,224 @@ void main() {
|
|||
await controller.stop();
|
||||
});
|
||||
|
||||
test(
|
||||
'responseDone finalizes assistant speech while the message still streams',
|
||||
() async {
|
||||
final input = _FakeVoiceInputService();
|
||||
final tts = _FakeTextToSpeechService()..holdCompletion = true;
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
...openWebUiStorageOpenOverrides(),
|
||||
authNavigationStateProvider.overrideWithValue(
|
||||
AuthNavigationState.authenticated,
|
||||
),
|
||||
selectedModelProvider.overrideWithValue(_model),
|
||||
appSettingsProvider.overrideWithValue(const AppSettings()),
|
||||
reviewerModeProvider.overrideWithValue(true),
|
||||
voiceInputServiceProvider.overrideWithValue(input),
|
||||
textToSpeechServiceProvider.overrideWithValue(tts),
|
||||
callKitServiceProvider.overrideWithValue(
|
||||
_UnavailableCallKitService(),
|
||||
),
|
||||
chatVoiceModeBackgroundCoordinatorProvider.overrideWithValue(
|
||||
_FakeChatVoiceBackgroundCoordinator(),
|
||||
),
|
||||
chatVoiceAudioSessionCoordinatorProvider.overrideWithValue(
|
||||
_FakeChatVoiceAudioSessionCoordinator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final controller = container.read(
|
||||
chatVoiceModeControllerProvider.notifier,
|
||||
);
|
||||
|
||||
await controller.start(startNewConversation: false);
|
||||
await input.completeCurrent('response done marker unique voice turn');
|
||||
await _until(
|
||||
() =>
|
||||
container.read(chatVoiceModeControllerProvider).phase ==
|
||||
ChatVoiceModePhase.sending,
|
||||
);
|
||||
final assistant = _lastAssistant(container);
|
||||
check(assistant.isStreaming).isTrue();
|
||||
check(tts.finishedTexts).isEmpty();
|
||||
|
||||
// Mirror the transport: a terminal finish reason flushes the buffer and
|
||||
// marks responseDone without touching isStreaming.
|
||||
container
|
||||
.read(chatMessagesProvider.notifier)
|
||||
.updateMessageById(
|
||||
assistant.id,
|
||||
(m) => m.copyWith(
|
||||
content: 'Response settled before transport close.',
|
||||
metadata: {...?m.metadata, 'responseDone': true},
|
||||
),
|
||||
);
|
||||
|
||||
await _until(() => tts.finishedTexts.isNotEmpty);
|
||||
check(_lastAssistant(container).isStreaming).isTrue();
|
||||
check(tts.finishedTexts.length).equals(1);
|
||||
check(tts.finishedTexts.single ?? '').isNotEmpty();
|
||||
|
||||
// The later done event flips isStreaming; it must not finalize twice.
|
||||
await _until(() => !_lastAssistant(container).isStreaming);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
check(tts.finishedTexts.length).equals(1);
|
||||
|
||||
await controller.stop();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'responseDone speaks the settled message, not a stale streaming frame',
|
||||
() async {
|
||||
final input = _FakeVoiceInputService();
|
||||
final tts = _FakeTextToSpeechService()..holdCompletion = true;
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
...openWebUiStorageOpenOverrides(),
|
||||
authNavigationStateProvider.overrideWithValue(
|
||||
AuthNavigationState.authenticated,
|
||||
),
|
||||
selectedModelProvider.overrideWithValue(_model),
|
||||
appSettingsProvider.overrideWithValue(const AppSettings()),
|
||||
reviewerModeProvider.overrideWithValue(true),
|
||||
voiceInputServiceProvider.overrideWithValue(input),
|
||||
textToSpeechServiceProvider.overrideWithValue(tts),
|
||||
callKitServiceProvider.overrideWithValue(
|
||||
_UnavailableCallKitService(),
|
||||
),
|
||||
chatVoiceModeBackgroundCoordinatorProvider.overrideWithValue(
|
||||
_FakeChatVoiceBackgroundCoordinator(),
|
||||
),
|
||||
chatVoiceAudioSessionCoordinatorProvider.overrideWithValue(
|
||||
_FakeChatVoiceAudioSessionCoordinator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final controller = container.read(
|
||||
chatVoiceModeControllerProvider.notifier,
|
||||
);
|
||||
|
||||
await controller.start(startNewConversation: false);
|
||||
await input.completeCurrent('stale streaming frame unique voice turn');
|
||||
await _until(
|
||||
() =>
|
||||
container.read(chatVoiceModeControllerProvider).phase ==
|
||||
ChatVoiceModePhase.sending,
|
||||
);
|
||||
final assistant = _lastAssistant(container);
|
||||
check(assistant.isStreaming).isTrue();
|
||||
|
||||
// The visible streaming frame lags behind the flushed message when the
|
||||
// terminal finish reason lands; the message is the settled text.
|
||||
container
|
||||
.read(streamingContentProvider.notifier)
|
||||
.set('The settled answer is');
|
||||
container
|
||||
.read(chatMessagesProvider.notifier)
|
||||
.updateMessageById(
|
||||
assistant.id,
|
||||
(m) => m.copyWith(
|
||||
content: 'The settled answer is complete and final.',
|
||||
metadata: {...?m.metadata, 'responseDone': true},
|
||||
),
|
||||
);
|
||||
|
||||
await _until(() => tts.finishedTexts.isNotEmpty);
|
||||
check(tts.finishedTexts.single ?? '')
|
||||
.equals('The settled answer is complete and final.');
|
||||
|
||||
await controller.stop();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'responseDone while paused defers speech and flushes once on resume',
|
||||
() async {
|
||||
final input = _FakeVoiceInputService();
|
||||
final tts = _FakeTextToSpeechService();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
...openWebUiStorageOpenOverrides(),
|
||||
authNavigationStateProvider.overrideWithValue(
|
||||
AuthNavigationState.authenticated,
|
||||
),
|
||||
selectedModelProvider.overrideWithValue(_model),
|
||||
appSettingsProvider.overrideWithValue(const AppSettings()),
|
||||
reviewerModeProvider.overrideWithValue(true),
|
||||
voiceInputServiceProvider.overrideWithValue(input),
|
||||
textToSpeechServiceProvider.overrideWithValue(tts),
|
||||
callKitServiceProvider.overrideWithValue(
|
||||
_UnavailableCallKitService(),
|
||||
),
|
||||
chatVoiceModeBackgroundCoordinatorProvider.overrideWithValue(
|
||||
_FakeChatVoiceBackgroundCoordinator(),
|
||||
),
|
||||
chatVoiceAudioSessionCoordinatorProvider.overrideWithValue(
|
||||
_FakeChatVoiceAudioSessionCoordinator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final controller = container.read(
|
||||
chatVoiceModeControllerProvider.notifier,
|
||||
);
|
||||
|
||||
await controller.start(startNewConversation: false);
|
||||
await input.completeCurrent('paused response done unique voice turn');
|
||||
await _until(
|
||||
() =>
|
||||
container.read(chatVoiceModeControllerProvider).phase ==
|
||||
ChatVoiceModePhase.sending,
|
||||
);
|
||||
|
||||
await controller.pause();
|
||||
expect(
|
||||
container.read(chatVoiceModeControllerProvider).phase,
|
||||
ChatVoiceModePhase.paused,
|
||||
);
|
||||
final assistant = _lastAssistant(container);
|
||||
check(assistant.isStreaming).isTrue();
|
||||
container
|
||||
.read(chatMessagesProvider.notifier)
|
||||
.updateMessageById(
|
||||
assistant.id,
|
||||
(m) => m.copyWith(
|
||||
content: 'Response settled before transport close.',
|
||||
metadata: {...?m.metadata, 'responseDone': true},
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
expect(
|
||||
container.read(chatVoiceModeControllerProvider).phase,
|
||||
ChatVoiceModePhase.paused,
|
||||
);
|
||||
check(tts.finishedTexts).isEmpty();
|
||||
|
||||
await controller.resume();
|
||||
await _until(() => tts.finishedTexts.isNotEmpty);
|
||||
// The deferred finalization flushes on resume, before transport close.
|
||||
check(_lastAssistant(container).isStreaming).isTrue();
|
||||
check(tts.finishedTexts.length).equals(1);
|
||||
check(tts.finishedTexts.single ?? '').isNotEmpty();
|
||||
|
||||
await _until(() => !_lastAssistant(container).isStreaming);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
check(tts.finishedTexts.length).equals(1);
|
||||
await _until(() => input.beginCalls == 2);
|
||||
expect(
|
||||
container.read(chatVoiceModeControllerProvider).phase,
|
||||
ChatVoiceModePhase.listening,
|
||||
);
|
||||
|
||||
await controller.stop();
|
||||
},
|
||||
);
|
||||
|
||||
test('unmuting a paused assistant turn resumes its speech', () async {
|
||||
final input = _FakeVoiceInputService();
|
||||
final tts = _FakeTextToSpeechService()..holdCompletion = true;
|
||||
|
|
@ -3279,6 +3497,10 @@ class _VoiceSocketTestController extends Notifier<SocketService?> {
|
|||
void set(SocketService? socket) => state = socket;
|
||||
}
|
||||
|
||||
ChatMessage _lastAssistant(ProviderContainer container) => container
|
||||
.read(chatMessagesProvider)
|
||||
.lastWhere((message) => message.role == 'assistant');
|
||||
|
||||
Future<void> _until(bool Function() condition) async {
|
||||
for (var i = 0; i < 300; i++) {
|
||||
if (condition()) return;
|
||||
|
|
|
|||
|
|
@ -732,6 +732,57 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test('OpenRouter discovery drops Batch API-only model variants', () async {
|
||||
final http = _QueuedAdapter([
|
||||
_Reply.json({
|
||||
'data': [
|
||||
{'id': 'anthropic/claude-sonnet-4:batch'},
|
||||
{'id': 'anthropic/claude-sonnet-4'},
|
||||
{'id': 'x:free'},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
final adapter = OpenAiCompatibleAdapter(
|
||||
dioFactory: (_) => _dio(http),
|
||||
closeClients: false,
|
||||
);
|
||||
|
||||
final models = await adapter.listModels(
|
||||
_openAiProfile(baseUrl: kOpenRouterApiBaseUrl),
|
||||
);
|
||||
|
||||
expect(models.map((model) => model.id), [
|
||||
'anthropic/claude-sonnet-4',
|
||||
'x:free',
|
||||
]);
|
||||
expect(
|
||||
http.requests.single.uri.toString(),
|
||||
'https://openrouter.ai/api/v1/models/user',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-OpenRouter discovery keeps batch-suffixed model ids', () async {
|
||||
final http = _QueuedAdapter([
|
||||
_Reply.json({
|
||||
'data': [
|
||||
{'id': 'custom-model:batch'},
|
||||
{'id': 'custom-model'},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
final adapter = OpenAiCompatibleAdapter(
|
||||
dioFactory: (_) => _dio(http),
|
||||
closeClients: false,
|
||||
);
|
||||
|
||||
final models = await adapter.listModels(_openAiProfile());
|
||||
|
||||
expect(models.map((model) => model.id), [
|
||||
'custom-model:batch',
|
||||
'custom-model',
|
||||
]);
|
||||
});
|
||||
|
||||
test('OpenRouter discovery normalizes model reasoning metadata', () async {
|
||||
final http = _QueuedAdapter([
|
||||
_Reply.json({
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:conduit/core/services/secure_credential_storage.dart';
|
|||
import 'package:conduit/features/direct_connections/models/direct_completion.dart';
|
||||
import 'package:conduit/core/platform/conduit_platform_apis.g.dart';
|
||||
import 'package:conduit/features/direct_connections/services/apple_pcc_adapter.dart';
|
||||
import 'package:conduit/features/direct_connections/services/direct_adapter_helpers.dart';
|
||||
import 'package:conduit/features/direct_connections/models/direct_connection_profile.dart';
|
||||
import 'package:conduit/features/direct_connections/models/direct_remote_model.dart';
|
||||
import 'package:conduit/features/direct_connections/providers/direct_connection_providers.dart';
|
||||
|
|
@ -1463,7 +1464,9 @@ void main() {
|
|||
|
||||
await controller.blockMutationsForAppDataClear();
|
||||
await expectLater(controller.upsert(_profile()), throwsStateError);
|
||||
await expectLater(controller.probe(_profile()), throwsStateError);
|
||||
final blockedProbe = await controller.probe(_profile());
|
||||
check(blockedProbe.reachable).isFalse();
|
||||
check(blockedProbe.message).isNotNull().contains('app data');
|
||||
|
||||
controller.resumeMutationsAfterAppDataClearAbort();
|
||||
await controller.upsert(_profile());
|
||||
|
|
@ -1584,7 +1587,7 @@ void main() {
|
|||
);
|
||||
|
||||
test(
|
||||
'incomplete logout fence suppresses Direct profiles on restart',
|
||||
'incomplete logout fence does not hide Direct profiles or block setup',
|
||||
() async {
|
||||
await PreferencesStore.putChecked(
|
||||
PreferenceKeys.incompleteLogoutFence,
|
||||
|
|
@ -1595,21 +1598,243 @@ void main() {
|
|||
_profile(),
|
||||
]).encode(),
|
||||
});
|
||||
final container = _container(_QueuedAdapter());
|
||||
final adapter = _CountingProbeAdapter();
|
||||
final container = _container(adapter);
|
||||
addTearDown(container.dispose);
|
||||
check(container.read(incompleteLogoutFenceProvider)).isTrue();
|
||||
|
||||
expect(
|
||||
await container.read(directConnectionProfilesProvider.future),
|
||||
hasLength(1),
|
||||
);
|
||||
final controller = container.read(
|
||||
directConnectionProfilesProvider.notifier,
|
||||
);
|
||||
|
||||
final probe = await controller.probe(_profile(id: 'profile-two'));
|
||||
check(probe.reachable).isTrue();
|
||||
check(adapter.probeCalls).equals(1);
|
||||
|
||||
await controller.upsert(_profile(id: 'profile-two'));
|
||||
expect(
|
||||
container.read(directConnectionProfilesProvider).requireValue,
|
||||
hasLength(2),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'incomplete app-data clear hides surviving profiles until the fence lifts',
|
||||
() async {
|
||||
final adapter = _CountingProbeAdapter();
|
||||
final container = _container(adapter);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
final controller = container.read(
|
||||
directConnectionProfilesProvider.notifier,
|
||||
);
|
||||
await controller.upsert(_profile());
|
||||
final fence = container.read(incompleteLogoutFenceProvider.notifier);
|
||||
fence.setSuppressed(true);
|
||||
|
||||
await controller.blockMutationsForAppDataClear();
|
||||
// The coordinator still holds the preference barrier at this point; the
|
||||
// marker must land regardless so it survives a restart.
|
||||
await PreferencesStore.blockWritesForAppDataClear();
|
||||
await controller.revokeRuntimeAfterIncompleteAppDataClear();
|
||||
PreferencesStore.resumeWritesAfterAppDataClear();
|
||||
|
||||
expect(
|
||||
container.read(directConnectionProfilesProvider).requireValue,
|
||||
isEmpty,
|
||||
);
|
||||
check(PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear))
|
||||
.equals(true);
|
||||
final probe = await controller.probe(_profile(id: 'profile-two'));
|
||||
check(probe.reachable).isFalse();
|
||||
check(adapter.probeCalls).equals(0);
|
||||
await expectLater(
|
||||
controller.upsert(_profile(id: 'profile-two')),
|
||||
throwsStateError,
|
||||
);
|
||||
|
||||
// A rebuild while the fence is still up stays blocked.
|
||||
container.invalidate(directConnectionProfilesProvider);
|
||||
expect(
|
||||
await container.read(directConnectionProfilesProvider.future),
|
||||
isEmpty,
|
||||
);
|
||||
await expectLater(
|
||||
container
|
||||
.read(directConnectionProfilesProvider.notifier)
|
||||
.upsert(_profile()),
|
||||
throwsStateError,
|
||||
|
||||
// A completed cleanup or a new authenticated session clears the fence
|
||||
// and releases the block; the surviving profile is visible again.
|
||||
fence.setSuppressed(false);
|
||||
expect(
|
||||
await container.read(directConnectionProfilesProvider.future),
|
||||
hasLength(1),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
check(PreferencesStore.getBool(PreferenceKeys.incompleteAppDataClear))
|
||||
.isNull();
|
||||
await controller.upsert(_profile(id: 'profile-two'));
|
||||
expect(
|
||||
container.read(directConnectionProfilesProvider).requireValue,
|
||||
hasLength(2),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a failed incomplete-clear marker write propagates instead of hiding',
|
||||
() async {
|
||||
PreferencesStore.debugOverride(
|
||||
await SharedPreferences.getInstance(),
|
||||
writeInterceptor: (prefs, key, value) async =>
|
||||
key == PreferenceKeys.incompleteAppDataClear ? false : null,
|
||||
);
|
||||
final container = _container(_CountingProbeAdapter());
|
||||
addTearDown(container.dispose);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
final controller = container.read(
|
||||
directConnectionProfilesProvider.notifier,
|
||||
);
|
||||
await controller.upsert(_profile());
|
||||
await controller.blockMutationsForAppDataClear();
|
||||
|
||||
await expectLater(
|
||||
controller.revokeRuntimeAfterIncompleteAppDataClear(),
|
||||
throwsStateError,
|
||||
);
|
||||
// The in-memory block still holds for this process.
|
||||
expect(
|
||||
container.read(directConnectionProfilesProvider).requireValue,
|
||||
isEmpty,
|
||||
);
|
||||
await expectLater(controller.upsert(_profile()), throwsStateError);
|
||||
},
|
||||
);
|
||||
|
||||
test('probe reports a validation failure instead of throwing', () async {
|
||||
final adapter = _CountingProbeAdapter();
|
||||
final container = _container(adapter);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
final controller = container.read(
|
||||
directConnectionProfilesProvider.notifier,
|
||||
);
|
||||
|
||||
final probe = await controller.probe(_profile(name: ' '));
|
||||
check(probe.reachable).isFalse();
|
||||
check(probe.message).equals('Profile name is required.');
|
||||
check(adapter.probeCalls).equals(0);
|
||||
});
|
||||
|
||||
test('probe reports a missing adapter instead of throwing', () async {
|
||||
final container = _container(_CountingProbeAdapter());
|
||||
addTearDown(container.dispose);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
final controller = container.read(
|
||||
directConnectionProfilesProvider.notifier,
|
||||
);
|
||||
|
||||
final probe = await controller.probe(_profile(adapterKey: 'unknown'));
|
||||
check(probe.reachable).isFalse();
|
||||
check(probe.message).isNotNull().contains('not available');
|
||||
});
|
||||
|
||||
group('HTTP 401 auth-mode hint', () {
|
||||
DioException unauthorized() {
|
||||
final request = RequestOptions(path: '/models');
|
||||
return DioException(
|
||||
requestOptions: request,
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response<void>(requestOptions: request, statusCode: 401),
|
||||
);
|
||||
}
|
||||
|
||||
test('thrown 401 with api-key header mode mentions Bearer', () async {
|
||||
final container = _container(
|
||||
_UnsafeMessageAdapter(probeError: unauthorized()),
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
final controller = container.read(
|
||||
directConnectionProfilesProvider.notifier,
|
||||
);
|
||||
|
||||
final probe = await controller.probe(
|
||||
_profile(apiKey: 'secret-key')
|
||||
.copyWith(apiKeyAuthMode: DirectApiKeyAuthMode.apiKeyHeader),
|
||||
);
|
||||
check(probe.reachable).isFalse();
|
||||
check(probe.message)
|
||||
.equals('The provider returned HTTP 401. $kDirectBearerAuthModeHint');
|
||||
});
|
||||
|
||||
test(
|
||||
'returned 401 probe with api-key header mode mentions Bearer',
|
||||
() async {
|
||||
final container = _container(
|
||||
_UnsafeMessageAdapter(
|
||||
probeResult: const DirectConnectionProbe(
|
||||
reachable: false,
|
||||
message: 'The provider returned HTTP 401.',
|
||||
),
|
||||
),
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
final controller = container.read(
|
||||
directConnectionProfilesProvider.notifier,
|
||||
);
|
||||
|
||||
final probe = await controller.probe(
|
||||
_profile(apiKey: 'secret-key')
|
||||
.copyWith(apiKeyAuthMode: DirectApiKeyAuthMode.apiKeyHeader),
|
||||
);
|
||||
check(probe.reachable).isFalse();
|
||||
check(probe.message).isNotNull().contains(kDirectBearerAuthModeHint);
|
||||
},
|
||||
);
|
||||
|
||||
test('401 with bearer mode does not mention the hint', () async {
|
||||
final container = _container(
|
||||
_UnsafeMessageAdapter(probeError: unauthorized()),
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(directConnectionProfilesProvider.future);
|
||||
final controller = container.read(
|
||||
directConnectionProfilesProvider.notifier,
|
||||
);
|
||||
|
||||
final probe = await controller.probe(_profile(apiKey: 'secret-key'));
|
||||
check(probe.reachable).isFalse();
|
||||
check(probe.message).equals('The provider returned HTTP 401.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
final class _CountingProbeAdapter implements DirectProviderAdapter {
|
||||
int probeCalls = 0;
|
||||
|
||||
@override
|
||||
String get key => kOpenAiCompatibleAdapterKey;
|
||||
|
||||
@override
|
||||
Future<List<DirectRemoteModel>> listModels(
|
||||
DirectConnectionProfile profile,
|
||||
) async => const [];
|
||||
|
||||
@override
|
||||
Future<DirectConnectionProbe> probe(DirectConnectionProfile profile) async {
|
||||
probeCalls++;
|
||||
return const DirectConnectionProbe(reachable: true);
|
||||
}
|
||||
|
||||
@override
|
||||
DirectCompletionRun startCompletion(
|
||||
DirectConnectionProfile profile,
|
||||
DirectCompletionRequest request,
|
||||
) => throw UnimplementedError();
|
||||
}
|
||||
|
||||
ProviderContainer _container(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:conduit/features/hermes/models/hermes_config.dart';
|
|||
import 'package:conduit/features/hermes/models/hermes_job.dart';
|
||||
import 'package:conduit/features/hermes/models/hermes_run_event.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_api_service.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_json_guard.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
|
@ -376,7 +377,7 @@ void main() {
|
|||
|
||||
await check(
|
||||
service.getSessionMessages('session-1', cancelToken: cancelToken),
|
||||
).throws<HermesStreamGuardException>();
|
||||
).throws<HermesResponseTooLargeException>();
|
||||
|
||||
check(cancelToken.isCancelled).isTrue();
|
||||
check(hermesCancellationWasInternal(cancelToken)).isTrue();
|
||||
|
|
@ -396,7 +397,7 @@ void main() {
|
|||
await check(
|
||||
_service(capture)
|
||||
.getSessionMessages('session-1', cancelToken: cancelToken),
|
||||
).throws<HermesStreamGuardException>();
|
||||
).throws<HermesResponseTooLargeException>();
|
||||
|
||||
check(cancelToken.isCancelled).isTrue();
|
||||
check(hermesCancellationWasInternal(cancelToken)).isTrue();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'dart:io';
|
|||
import 'package:checks/checks.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_desktop_api_service.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_desktop_transport.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_json_guard.dart';
|
||||
import 'package:conduit/features/hermes/models/hermes_config.dart';
|
||||
import 'package:conduit/features/hermes/models/hermes_mcp.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
|
@ -139,10 +140,75 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
check(offsets).deepEquals([0, 500]);
|
||||
check(offsets).deepEquals([0, 200, 400, 600]);
|
||||
check(messages).length.equals(750);
|
||||
});
|
||||
|
||||
test('a short final transcript page stops paging', () async {
|
||||
final requests = <(int, int)>[];
|
||||
final messages = await loadHermesDesktopTranscriptPages((
|
||||
offset,
|
||||
limit,
|
||||
) async {
|
||||
requests.add((offset, limit));
|
||||
final remaining = 230 - offset;
|
||||
return List.generate(
|
||||
remaining.clamp(0, limit),
|
||||
(index) => {'id': '${offset + index}'},
|
||||
);
|
||||
});
|
||||
|
||||
check(requests).deepEquals([(0, 200), (200, 200)]);
|
||||
check(messages).length.equals(230);
|
||||
});
|
||||
|
||||
test('an oversized transcript page is retried at half the limit', () async {
|
||||
final requests = <(int, int)>[];
|
||||
final messages = await loadHermesDesktopTranscriptPages((
|
||||
offset,
|
||||
limit,
|
||||
) async {
|
||||
requests.add((offset, limit));
|
||||
if (limit > 100) throw const HermesResponseTooLargeException();
|
||||
final remaining = 250 - offset;
|
||||
return List.generate(
|
||||
remaining.clamp(0, limit),
|
||||
(index) => {'id': '${offset + index}'},
|
||||
);
|
||||
});
|
||||
|
||||
check(requests).deepEquals([(0, 200), (0, 100), (100, 100), (200, 100)]);
|
||||
check(messages).length.equals(250);
|
||||
check(messages.first['id']).equals('0');
|
||||
check(messages.last['id']).equals('249');
|
||||
});
|
||||
|
||||
test('a transcript page that is too large at the floor surfaces', () async {
|
||||
final limits = <int>[];
|
||||
await check(
|
||||
loadHermesDesktopTranscriptPages((offset, limit) async {
|
||||
limits.add(limit);
|
||||
throw const HermesResponseTooLargeException();
|
||||
}),
|
||||
).throws<HermesResponseTooLargeException>();
|
||||
|
||||
check(limits).deepEquals([200, 100, 50, 25]);
|
||||
});
|
||||
|
||||
test('the transcript pager honours the total maximum', () async {
|
||||
final requests = <(int, int)>[];
|
||||
final messages = await loadHermesDesktopTranscriptPages((
|
||||
offset,
|
||||
limit,
|
||||
) async {
|
||||
requests.add((offset, limit));
|
||||
return List.generate(limit, (index) => {'id': '${offset + index}'});
|
||||
}, maximum: 450);
|
||||
|
||||
check(requests).deepEquals([(0, 200), (200, 200), (400, 50)]);
|
||||
check(messages).length.equals(450);
|
||||
});
|
||||
|
||||
test('keeps the last usable transcript across empty or partial reads', () {
|
||||
final previous = [
|
||||
{'id': 'one'},
|
||||
|
|
|
|||
|
|
@ -42,7 +42,17 @@ final class _GatewayHarness {
|
|||
sent.add(frame);
|
||||
final id = frame['id'];
|
||||
if (id == null) return;
|
||||
final result = responder(frame['method']?.toString() ?? '');
|
||||
final method = frame['method']?.toString() ?? '';
|
||||
final error = errorResponder(method);
|
||||
if (error != null) {
|
||||
scheduleMicrotask(
|
||||
() => incoming.add(
|
||||
jsonEncode({'jsonrpc': '2.0', 'id': id, 'error': error}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final result = responder(method);
|
||||
if (result == null) return;
|
||||
scheduleMicrotask(
|
||||
() => incoming.add(
|
||||
|
|
@ -59,6 +69,10 @@ final class _GatewayHarness {
|
|||
|
||||
Map<String, dynamic>? Function(String method) responder = (_) => null;
|
||||
|
||||
/// When non-null for a method, the gateway answers with a JSON-RPC error
|
||||
/// instead of a result.
|
||||
Map<String, dynamic>? Function(String method) errorResponder = (_) => null;
|
||||
|
||||
void ready() {
|
||||
if (incoming.isClosed) return;
|
||||
incoming.add(
|
||||
|
|
@ -95,6 +109,9 @@ Dio _statusDio([_StubAdapter? adapter]) {
|
|||
/// Serves `/api/status` (auth not required) so `_connect` can reach the socket,
|
||||
/// and records every REST URI so tests can assert profile scoping.
|
||||
final class _StubAdapter implements HttpClientAdapter {
|
||||
_StubAdapter({this.messages = const <Map<String, dynamic>>[]});
|
||||
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final requested = <Uri>[];
|
||||
|
||||
@override
|
||||
|
|
@ -109,7 +126,7 @@ final class _StubAdapter implements HttpClientAdapter {
|
|||
requested.add(options.uri);
|
||||
if (options.uri.path.endsWith('/messages')) {
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode({'messages': const []}),
|
||||
jsonEncode({'messages': messages}),
|
||||
200,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
|
|
@ -131,6 +148,32 @@ final class _StubAdapter implements HttpClientAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Serves `/api/status` like [_StubAdapter] but fails the transcript request.
|
||||
final class _FailingMessagesAdapter extends _StubAdapter {
|
||||
_FailingMessagesAdapter({required this.status});
|
||||
|
||||
final int status;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
if (options.uri.path.endsWith('/messages')) {
|
||||
requested.add(options.uri);
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode({'error': 'session_not_found'}),
|
||||
status,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
return super.fetch(options, requestStream, cancelFuture);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
tearDown(PreferencesStore.debugReset);
|
||||
|
|
@ -279,6 +322,107 @@ void main() {
|
|||
check(historyRequest.queryParameters['order']).equals('latest');
|
||||
});
|
||||
|
||||
test(
|
||||
'a session the gateway refuses to resume still loads over REST',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
PreferencesStore.debugOverride(await SharedPreferences.getInstance());
|
||||
final harness = _GatewayHarness();
|
||||
final adapter = _StubAdapter(
|
||||
messages: const [
|
||||
{'role': 'user', 'content': 'hello from telegram'},
|
||||
{'role': 'assistant', 'content': 'hi'},
|
||||
],
|
||||
);
|
||||
final rpc = HermesDesktopRpcClient(
|
||||
channelFactory: (_, _, {httpClient}) => harness.channel,
|
||||
);
|
||||
final service = HermesDesktopApiService(
|
||||
config: HermesConfig(
|
||||
enabled: true,
|
||||
baseUrl: 'https://hermes.example',
|
||||
mode: HermesBackendMode.desktopGateway,
|
||||
desktopCredentials: HermesDesktopCredentials(
|
||||
legacyToken: 'session-token',
|
||||
),
|
||||
),
|
||||
dio: _statusDio(adapter),
|
||||
rpc: rpc,
|
||||
);
|
||||
addTearDown(() async {
|
||||
service.close();
|
||||
await harness.dispose();
|
||||
});
|
||||
final turnStates = <HermesDesktopTurnState>[];
|
||||
final subscription = service.turnStates.listen(turnStates.add);
|
||||
addTearDown(subscription.cancel);
|
||||
|
||||
harness.errorResponder = (method) => switch (method) {
|
||||
'session.resume' => {
|
||||
'code': 4130,
|
||||
'message': 'Session too large to resume',
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
|
||||
final messages = await service.getSessionMessages('telegram:123');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
check(messages).length.equals(2);
|
||||
check(messages.last['content']).equals('hi');
|
||||
final historyRequest = adapter.requested.singleWhere(
|
||||
(uri) => uri.path.endsWith('/messages'),
|
||||
);
|
||||
check(historyRequest.path)
|
||||
.endsWith('/api/sessions/telegram%3A123/messages');
|
||||
check(historyRequest.queryParameters['order']).equals('oldest');
|
||||
check(historyRequest.queryParameters['limit']).equals('200');
|
||||
check(turnStates.last).equals(HermesDesktopTurnState.idle);
|
||||
},
|
||||
);
|
||||
|
||||
test('a REST failure after a refused resume is not swallowed', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
PreferencesStore.debugOverride(await SharedPreferences.getInstance());
|
||||
final harness = _GatewayHarness();
|
||||
final adapter = _FailingMessagesAdapter(status: 404);
|
||||
final rpc = HermesDesktopRpcClient(
|
||||
channelFactory: (_, _, {httpClient}) => harness.channel,
|
||||
);
|
||||
final service = HermesDesktopApiService(
|
||||
config: HermesConfig(
|
||||
enabled: true,
|
||||
baseUrl: 'https://hermes.example',
|
||||
mode: HermesBackendMode.desktopGateway,
|
||||
desktopCredentials: HermesDesktopCredentials(
|
||||
legacyToken: 'session-token',
|
||||
),
|
||||
),
|
||||
dio: _statusDio(adapter),
|
||||
rpc: rpc,
|
||||
);
|
||||
addTearDown(() async {
|
||||
service.close();
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
harness.errorResponder = (method) => switch (method) {
|
||||
'session.resume' => {'code': 4007, 'message': 'Session not found'},
|
||||
_ => null,
|
||||
};
|
||||
|
||||
Object? failure;
|
||||
try {
|
||||
await service.getSessionMessages('stored-missing');
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
check(failure)
|
||||
.isA<DioException>()
|
||||
.has((error) => error.response?.statusCode, 'status')
|
||||
.equals(404);
|
||||
});
|
||||
|
||||
test('a new bot chat opens before its first prompt is persisted', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
PreferencesStore.debugOverride(await SharedPreferences.getInstance());
|
||||
|
|
|
|||
|
|
@ -12,10 +12,14 @@ import 'package:conduit/features/hermes/models/hermes_model.dart';
|
|||
import 'package:conduit/features/hermes/models/hermes_session.dart';
|
||||
import 'package:conduit/features/hermes/providers/hermes_providers.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_api_service.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_desktop_transport.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_json_guard.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_local_document_service.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_local_document_trust_store.dart';
|
||||
import 'package:conduit/features/hermes/services/hermes_session_provenance.dart';
|
||||
import 'package:conduit/features/hermes/widgets/hermes_session_tile.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import 'package:conduit/l10n/app_localizations_en.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
|
@ -552,6 +556,131 @@ void main() {
|
|||
});
|
||||
}
|
||||
|
||||
group('open failures', () {
|
||||
final l10n = AppLocalizationsEn();
|
||||
final failureScenarios = <String, (Object, String)>{
|
||||
'an oversized transcript': (
|
||||
const HermesResponseTooLargeException(),
|
||||
l10n.hermesSessionTooLarge,
|
||||
),
|
||||
'a session.resume not-found reply': (
|
||||
const HermesDesktopRpcException('Session not found', code: 4007),
|
||||
l10n.hermesSessionNotFound,
|
||||
),
|
||||
'a REST 404': (
|
||||
DioException(
|
||||
requestOptions: RequestOptions(path: '/api/sessions/x/messages'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/api/sessions/x/messages'),
|
||||
statusCode: 404,
|
||||
),
|
||||
),
|
||||
l10n.hermesSessionNotFound,
|
||||
),
|
||||
'another gateway RPC error': (
|
||||
const HermesDesktopRpcException(
|
||||
'Session too large to resume',
|
||||
code: 4130,
|
||||
),
|
||||
l10n.hermesSessionLoadFailedDetail(
|
||||
'Session too large to resume (4130)',
|
||||
),
|
||||
),
|
||||
'an unrelated failure': (
|
||||
StateError('socket closed'),
|
||||
l10n.hermesSessionLoadFailed,
|
||||
),
|
||||
};
|
||||
|
||||
for (final scenario in failureScenarios.entries) {
|
||||
testWidgets('${scenario.key} shows a specific message and does not '
|
||||
'open the chat', (tester) async {
|
||||
final (error, expectedMessage) = scenario.value;
|
||||
final service = _FakeHermesApiService(messagesError: error);
|
||||
final container = ProviderContainer(
|
||||
retry: (retryCount, error) => null,
|
||||
overrides: [hermesApiServiceProvider.overrideWithValue(service)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
late BuildContext actionContext;
|
||||
late WidgetRef widgetRef;
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => Consumer(
|
||||
builder: (context, ref, child) {
|
||||
actionContext = context;
|
||||
widgetRef = ref;
|
||||
return const Scaffold(body: SizedBox.shrink());
|
||||
},
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.chat,
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: SizedBox.shrink()),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
NavigationService.attachRouter(router);
|
||||
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: MaterialApp.router(
|
||||
routerConfig: router,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openHermesSession(
|
||||
actionContext,
|
||||
widgetRef,
|
||||
const HermesSessionSummary(id: 'session-1', title: 'Saved session'),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
check(find.text(expectedMessage).evaluate()).length.equals(1);
|
||||
check(container.read(hermesActiveSessionProvider)).isNull();
|
||||
check(container.read(activeConversationProvider)).isNull();
|
||||
check(router.state.uri.path).equals('/');
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
|
||||
test('server-provided detail is bounded and stripped of control text', () {
|
||||
final message = hermesSessionLoadFailureMessage(
|
||||
l10n,
|
||||
HermesDesktopRpcException(
|
||||
'line one\nline\u0000two ${'x' * 300}',
|
||||
code: 5000,
|
||||
),
|
||||
);
|
||||
|
||||
check(message).startsWith('Could not load this conversation: ');
|
||||
check(message).not((it) => it.contains('\n'));
|
||||
check(message).not((it) => it.contains('\u0000'));
|
||||
check(message).contains('line one line two');
|
||||
check(message).endsWith('\u2026 (5000)');
|
||||
check(message.length).isLessThan(200);
|
||||
});
|
||||
|
||||
test('timed-out or disconnected RPC replies stay generic', () {
|
||||
check(
|
||||
hermesSessionLoadFailureMessage(
|
||||
l10n,
|
||||
const HermesDesktopRpcException('timed out', timedOut: true),
|
||||
),
|
||||
).equals(l10n.hermesSessionLoadFailed);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('opening a session restores locally trusted document prompts', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -646,6 +775,7 @@ class _FakeHermesApiService extends HermesApiService {
|
|||
],
|
||||
this.deleteGate,
|
||||
this.messagesGate,
|
||||
this.messagesError,
|
||||
}) : super(
|
||||
config: const HermesConfig(
|
||||
enabled: true,
|
||||
|
|
@ -657,6 +787,7 @@ class _FakeHermesApiService extends HermesApiService {
|
|||
final List<Map<String, dynamic>> messages;
|
||||
final Completer<void>? deleteGate;
|
||||
final Completer<void>? messagesGate;
|
||||
final Object? messagesError;
|
||||
final deleteStarted = Completer<void>();
|
||||
final messagesStarted = Completer<void>();
|
||||
final List<String> deletedSessionIds = [];
|
||||
|
|
@ -673,6 +804,7 @@ class _FakeHermesApiService extends HermesApiService {
|
|||
operationLog.add('get:$id');
|
||||
if (!messagesStarted.isCompleted) messagesStarted.complete();
|
||||
await messagesGate?.future;
|
||||
if (messagesError != null) throw messagesError!;
|
||||
return messages;
|
||||
}
|
||||
|
||||
|
|
|
|||
109
test/shared/widgets/model_list_tile_test.dart
Normal file
109
test/shared/widgets/model_list_tile_test.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import 'package:checks/checks.dart';
|
||||
import 'package:conduit/core/models/model.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import 'package:conduit/l10n/conduit_localizations.dart';
|
||||
import 'package:conduit/shared/theme/app_theme.dart';
|
||||
import 'package:conduit/shared/theme/tweakcn_themes.dart';
|
||||
import 'package:conduit/shared/theme/theme_extensions.dart';
|
||||
import 'package:conduit/shared/widgets/conduit_components.dart';
|
||||
import 'package:conduit/shared/widgets/horizontal_overflow_fade.dart';
|
||||
import 'package:conduit/shared/widgets/model_list_tile.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _reasoningModel = Model(
|
||||
id: 'reasoning-model',
|
||||
name: 'Reasoning model',
|
||||
supportedParameters: ['reasoning'],
|
||||
);
|
||||
|
||||
Future<ConduitThemeExtension> _pumpTile(
|
||||
WidgetTester tester, {
|
||||
required bool isSelected,
|
||||
}) async {
|
||||
late ConduitThemeExtension theme;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(TweakcnThemes.conduit),
|
||||
localizationsDelegates: conduitLocalizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
theme = context.conduitTheme;
|
||||
return ConduitCard(
|
||||
child: ModelListTile(
|
||||
model: _reasoningModel,
|
||||
isSelected: isSelected,
|
||||
onTap: () {},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return theme;
|
||||
}
|
||||
|
||||
List<Color> _fadeColors(WidgetTester tester) {
|
||||
final fade = find.byType(HorizontalOverflowFade);
|
||||
final gradient = tester
|
||||
.widgetList<DecoratedBox>(
|
||||
find.descendant(of: fade, matching: find.byType(DecoratedBox)),
|
||||
)
|
||||
.map((box) => box.decoration)
|
||||
.whereType<BoxDecoration>()
|
||||
.map((decoration) => decoration.gradient)
|
||||
.whereType<LinearGradient>()
|
||||
.single;
|
||||
return gradient.colors;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('unselected overflow fade derives from the card background', (
|
||||
tester,
|
||||
) async {
|
||||
final theme = await _pumpTile(tester, isSelected: false);
|
||||
final card = theme.cardBackground;
|
||||
|
||||
check(card).not((it) => it.equals(theme.surfaceBackground));
|
||||
|
||||
final colors = _fadeColors(tester);
|
||||
check(colors)
|
||||
.deepEquals([card.withValues(alpha: 0), card.withValues(alpha: 0.9)]);
|
||||
});
|
||||
|
||||
testWidgets('selected overflow fade matches the highlighted row surface', (
|
||||
tester,
|
||||
) async {
|
||||
final theme = await _pumpTile(tester, isSelected: true);
|
||||
final highlighted = Color.alphaBlend(
|
||||
theme.buttonPrimary.withValues(alpha: 0.1),
|
||||
theme.cardBackground,
|
||||
);
|
||||
final surfaceHighlighted = Color.alphaBlend(
|
||||
theme.buttonPrimary.withValues(alpha: 0.1),
|
||||
theme.surfaceBackground,
|
||||
);
|
||||
|
||||
check(highlighted).not((it) => it.equals(surfaceHighlighted));
|
||||
|
||||
final colors = _fadeColors(tester);
|
||||
check(colors).deepEquals([
|
||||
highlighted.withValues(alpha: 0),
|
||||
highlighted.withValues(alpha: 0.9),
|
||||
]);
|
||||
|
||||
// The highlight itself paints against the card surface.
|
||||
final tile = find.byType(ModelListTile);
|
||||
final containers = tester.widgetList<Container>(
|
||||
find.descendant(of: tile, matching: find.byType(Container)),
|
||||
);
|
||||
final rowBackgrounds = containers
|
||||
.map((container) => container.decoration)
|
||||
.whereType<BoxDecoration>()
|
||||
.map((decoration) => decoration.color);
|
||||
check(rowBackgrounds).contains(highlighted);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import 'package:conduit/core/models/backend_config.dart';
|
||||
import 'package:conduit/core/models/server_config.dart';
|
||||
import 'package:conduit/core/persistence/persistence_keys.dart';
|
||||
import 'package:conduit/core/persistence/preferences_store.dart';
|
||||
import 'package:conduit/core/providers/app_providers.dart';
|
||||
import 'package:conduit/features/auth/providers/unified_auth_providers.dart';
|
||||
import 'package:conduit/features/chat/views/chat_page.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import 'package:conduit/l10n/conduit_localizations.dart';
|
||||
import 'package:conduit/shared/widgets/server_version_warning_card.dart';
|
||||
import 'package:conduit/shared/widgets/server_version_warning_controller.dart';
|
||||
import 'package:checks/checks.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FixedBackendConfigNotifier extends BackendConfigNotifier {
|
||||
_FixedBackendConfigNotifier(this._config);
|
||||
|
|
@ -27,10 +31,11 @@ Widget _buildCard({
|
|||
required AuthNavigationState authState,
|
||||
required BackendConfig? config,
|
||||
Widget? body,
|
||||
String activeServerId = 'A',
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
activeServerProvider.overrideWith((ref) async => _server('A')),
|
||||
activeServerProvider.overrideWith((ref) async => _server(activeServerId)),
|
||||
backendConfigProvider.overrideWith(
|
||||
() => _FixedBackendConfigNotifier(config),
|
||||
),
|
||||
|
|
@ -47,8 +52,18 @@ Widget _buildCard({
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _seedPreferences([Map<String, Object> values = const {}]) async {
|
||||
SharedPreferences.setMockInitialValues(values);
|
||||
PreferencesStore.debugOverride(await SharedPreferences.getInstance());
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ServerVersionWarningCard', () {
|
||||
setUp(() async {
|
||||
await _seedPreferences();
|
||||
});
|
||||
tearDown(PreferencesStore.debugReset);
|
||||
|
||||
testWidgets('shows the localized warning for a newer active server', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -197,5 +212,112 @@ void main() {
|
|||
|
||||
check(find.text('Server not supported').evaluate()).isEmpty();
|
||||
});
|
||||
|
||||
testWidgets('close button hides the card and persists the token', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildCard(
|
||||
authState: AuthNavigationState.authenticated,
|
||||
config: const BackendConfig(version: '0.11.4', serverId: 'A'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
check(find.byKey(serverVersionWarningCardKey).evaluate()).length
|
||||
.equals(1);
|
||||
|
||||
await tester.tap(find.byKey(serverVersionWarningCardCloseKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
check(find.byKey(serverVersionWarningCardKey).evaluate()).isEmpty();
|
||||
check(
|
||||
decodeServerVersionWarningDismissals(
|
||||
PreferencesStore.getString(
|
||||
PreferenceKeys.serverVersionWarningDismissed,
|
||||
),
|
||||
),
|
||||
).deepEquals({'A|0.11.4'});
|
||||
});
|
||||
|
||||
testWidgets('dismissing a second server keeps the first dismissal', (
|
||||
tester,
|
||||
) async {
|
||||
await _seedPreferences({
|
||||
PreferenceKeys.serverVersionWarningDismissed: '["A|0.11.4"]',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildCard(
|
||||
authState: AuthNavigationState.authenticated,
|
||||
config: const BackendConfig(version: '0.11.6', serverId: 'B'),
|
||||
activeServerId: 'B',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(serverVersionWarningCardCloseKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
check(
|
||||
decodeServerVersionWarningDismissals(
|
||||
PreferencesStore.getString(
|
||||
PreferenceKeys.serverVersionWarningDismissed,
|
||||
),
|
||||
),
|
||||
).deepEquals({'A|0.11.4', 'B|0.11.6'});
|
||||
});
|
||||
|
||||
testWidgets('a bare legacy token still counts as dismissed', (
|
||||
tester,
|
||||
) async {
|
||||
await _seedPreferences({
|
||||
PreferenceKeys.serverVersionWarningDismissed: 'A|0.11.4',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildCard(
|
||||
authState: AuthNavigationState.authenticated,
|
||||
config: const BackendConfig(version: '0.11.4', serverId: 'A'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
check(find.byKey(serverVersionWarningCardKey).evaluate()).isEmpty();
|
||||
});
|
||||
|
||||
testWidgets('stays hidden when the same server and version was dismissed', (
|
||||
tester,
|
||||
) async {
|
||||
await _seedPreferences({
|
||||
PreferenceKeys.serverVersionWarningDismissed: '["A|0.11.4"]',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildCard(
|
||||
authState: AuthNavigationState.authenticated,
|
||||
config: const BackendConfig(version: '0.11.4', serverId: 'A'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
check(find.byKey(serverVersionWarningCardKey).evaluate()).isEmpty();
|
||||
});
|
||||
|
||||
testWidgets('shows again for a different server version', (tester) async {
|
||||
await _seedPreferences({
|
||||
PreferenceKeys.serverVersionWarningDismissed: '["A|0.11.4"]',
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildCard(
|
||||
authState: AuthNavigationState.authenticated,
|
||||
config: const BackendConfig(version: '0.11.5', serverId: 'A'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
check(find.byKey(serverVersionWarningCardKey).evaluate()).length
|
||||
.equals(1);
|
||||
check(find.textContaining('0.11.5').evaluate()).length.equals(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue