Merge branch 'codex/fix-thread-gateway-status'
This commit is contained in:
commit
8762d56d5e
@ -375,26 +375,65 @@ class AppController extends ChangeNotifier {
|
||||
return model.isEmpty ? appText('AI Gateway', 'AI Gateway') : model;
|
||||
}
|
||||
|
||||
String get assistantConnectionStatusLabel => isAiGatewayOnlyMode
|
||||
? appText('仅 AI Gateway', 'AI Gateway Only')
|
||||
: connection.status.label;
|
||||
AssistantThreadConnectionState get currentAssistantConnectionState =>
|
||||
assistantConnectionStateForSession(currentSessionKey);
|
||||
|
||||
AssistantThreadConnectionState assistantConnectionStateForSession(
|
||||
String sessionKey,
|
||||
) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final target = assistantExecutionTargetForSession(normalizedSessionKey);
|
||||
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
final model = assistantModelForSession(normalizedSessionKey);
|
||||
final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl);
|
||||
final detail = _joinConnectionParts(<String>[model, host]);
|
||||
return AssistantThreadConnectionState(
|
||||
executionTarget: target,
|
||||
status: canUseAiGatewayConversation
|
||||
? RuntimeConnectionStatus.connected
|
||||
: RuntimeConnectionStatus.offline,
|
||||
primaryLabel: target.label,
|
||||
detailLabel: detail.isEmpty
|
||||
? appText('AI Gateway 未配置', 'AI Gateway not configured')
|
||||
: detail,
|
||||
ready: canUseAiGatewayConversation,
|
||||
pairingRequired: false,
|
||||
gatewayTokenMissing: false,
|
||||
lastError: null,
|
||||
);
|
||||
}
|
||||
|
||||
final expectedMode = target == AssistantExecutionTarget.local
|
||||
? RuntimeConnectionMode.local
|
||||
: RuntimeConnectionMode.remote;
|
||||
final matchesTarget = connection.mode == expectedMode;
|
||||
final fallbackProfile = _gatewayProfileForAssistantExecutionTarget(target);
|
||||
final fallbackAddress = _gatewayAddressLabel(fallbackProfile);
|
||||
final detail = matchesTarget
|
||||
? (connection.remoteAddress?.trim().isNotEmpty == true
|
||||
? connection.remoteAddress!.trim()
|
||||
: fallbackAddress)
|
||||
: fallbackAddress;
|
||||
final status = matchesTarget
|
||||
? connection.status
|
||||
: RuntimeConnectionStatus.offline;
|
||||
return AssistantThreadConnectionState(
|
||||
executionTarget: target,
|
||||
status: status,
|
||||
primaryLabel: status.label,
|
||||
detailLabel: detail,
|
||||
ready: status == RuntimeConnectionStatus.connected,
|
||||
pairingRequired: matchesTarget && connection.pairingRequired,
|
||||
gatewayTokenMissing: matchesTarget && connection.gatewayTokenMissing,
|
||||
lastError: matchesTarget ? connection.lastError?.trim() : null,
|
||||
);
|
||||
}
|
||||
|
||||
String get assistantConnectionStatusLabel =>
|
||||
currentAssistantConnectionState.primaryLabel;
|
||||
|
||||
String get assistantConnectionTargetLabel {
|
||||
if (!isAiGatewayOnlyMode) {
|
||||
return connection.remoteAddress ?? appText('未连接目标', 'No target');
|
||||
}
|
||||
final model = resolvedAssistantModel;
|
||||
final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl);
|
||||
if (model.isNotEmpty && host.isNotEmpty) {
|
||||
return '$model · $host';
|
||||
}
|
||||
if (model.isNotEmpty) {
|
||||
return model;
|
||||
}
|
||||
if (host.isNotEmpty) {
|
||||
return host;
|
||||
}
|
||||
return appText('AI Gateway 未配置', 'AI Gateway not configured');
|
||||
return currentAssistantConnectionState.detailLabel;
|
||||
}
|
||||
|
||||
Future<String> loadAiGatewayApiKey() async {
|
||||
@ -664,6 +703,22 @@ class AppController extends ChangeNotifier {
|
||||
profile.mode != defaults.mode;
|
||||
}
|
||||
|
||||
String _joinConnectionParts(List<String> parts) {
|
||||
final normalized = parts
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
return normalized.join(' · ');
|
||||
}
|
||||
|
||||
String _gatewayAddressLabel(GatewayConnectionProfile profile) {
|
||||
final host = profile.host.trim();
|
||||
if (host.isEmpty || profile.port <= 0) {
|
||||
return appText('未连接目标', 'No target');
|
||||
}
|
||||
return '$host:${profile.port}';
|
||||
}
|
||||
|
||||
List<SecretReferenceEntry> get secretReferences =>
|
||||
_settingsController.buildSecretReferences();
|
||||
List<SecretAuditEntry> get secretAuditTrail => _settingsController.auditTrail;
|
||||
|
||||
@ -213,25 +213,52 @@ class AppController extends ChangeNotifier {
|
||||
_aiGatewayApiKeyCache.trim().isNotEmpty &&
|
||||
resolvedAiGatewayModel.isNotEmpty;
|
||||
|
||||
String get assistantConnectionStatusLabel => isAiGatewayOnlyMode
|
||||
? (canUseAiGatewayConversation
|
||||
? appText('可用', 'Ready')
|
||||
: appText('未配置', 'Not configured'))
|
||||
: connection.status.label;
|
||||
AssistantThreadConnectionState get currentAssistantConnectionState {
|
||||
final target = currentAssistantExecutionTarget;
|
||||
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
final host = _hostLabel(_settings.aiGateway.baseUrl);
|
||||
final model = resolvedAiGatewayModel;
|
||||
final detail = _joinConnectionParts(<String>[model, host]);
|
||||
return AssistantThreadConnectionState(
|
||||
executionTarget: target,
|
||||
status: canUseAiGatewayConversation
|
||||
? RuntimeConnectionStatus.connected
|
||||
: RuntimeConnectionStatus.offline,
|
||||
primaryLabel: target.label,
|
||||
detailLabel: detail.isEmpty
|
||||
? appText('Direct AI 未配置', 'Direct AI not configured')
|
||||
: detail,
|
||||
ready: canUseAiGatewayConversation,
|
||||
pairingRequired: false,
|
||||
gatewayTokenMissing: false,
|
||||
lastError: null,
|
||||
);
|
||||
}
|
||||
return AssistantThreadConnectionState(
|
||||
executionTarget: target,
|
||||
status: connection.status,
|
||||
primaryLabel: connection.status.label,
|
||||
detailLabel:
|
||||
connection.remoteAddress ?? appText('Relay 未连接', 'Relay offline'),
|
||||
ready: connection.status == RuntimeConnectionStatus.connected,
|
||||
pairingRequired: false,
|
||||
gatewayTokenMissing: false,
|
||||
lastError: null,
|
||||
);
|
||||
}
|
||||
|
||||
String get assistantConnectionStatusLabel =>
|
||||
currentAssistantConnectionState.primaryLabel;
|
||||
|
||||
String get assistantConnectionTargetLabel {
|
||||
if (!isAiGatewayOnlyMode) {
|
||||
return connection.remoteAddress ?? appText('Relay 未连接', 'Relay offline');
|
||||
}
|
||||
final host = _hostLabel(_settings.aiGateway.baseUrl);
|
||||
final model = resolvedAiGatewayModel;
|
||||
if (host.isEmpty && model.isEmpty) {
|
||||
return appText('Direct AI 未配置', 'Direct AI not configured');
|
||||
}
|
||||
if (host.isNotEmpty && model.isNotEmpty) {
|
||||
return '$model · $host';
|
||||
}
|
||||
return host.isNotEmpty ? host : model;
|
||||
return currentAssistantConnectionState.detailLabel;
|
||||
}
|
||||
|
||||
String _joinConnectionParts(List<String> parts) {
|
||||
return parts
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
String get conversationPersistenceSummary {
|
||||
|
||||
@ -606,6 +606,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
.map((item) => item.name)
|
||||
.toList(growable: false);
|
||||
final selectedSkillLabels = _resolveSelectedSkillLabels(controller);
|
||||
final connectionState = controller.currentAssistantConnectionState;
|
||||
final prompt = _composePrompt(
|
||||
mode: _mode,
|
||||
prompt: rawPrompt,
|
||||
@ -632,8 +633,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
status:
|
||||
controller.hasAssistantPendingRun ||
|
||||
executionTarget == AssistantExecutionTarget.aiGatewayOnly ||
|
||||
controller.connection.status ==
|
||||
RuntimeConnectionStatus.connected
|
||||
connectionState.connected
|
||||
? 'running'
|
||||
: 'queued',
|
||||
owner: autoAgent?.name ?? _conversationOwnerLabel(controller),
|
||||
@ -2214,11 +2214,9 @@ class _AssistantEmptyState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final connection = controller.connection;
|
||||
final aiGatewayOnly = controller.isAiGatewayOnlyMode;
|
||||
final connected = aiGatewayOnly
|
||||
? controller.canUseAiGatewayConversation
|
||||
: connection.status == RuntimeConnectionStatus.connected;
|
||||
final connectionState = controller.currentAssistantConnectionState;
|
||||
final aiGatewayOnly = connectionState.isAiGatewayOnly;
|
||||
final connected = connectionState.connected;
|
||||
final reconnectAvailable = controller.canQuickConnectGateway;
|
||||
final title = aiGatewayOnly
|
||||
? connected
|
||||
@ -2226,7 +2224,7 @@ class _AssistantEmptyState extends StatelessWidget {
|
||||
: appText('先配置 AI Gateway', 'Configure AI Gateway first')
|
||||
: connected
|
||||
? appText('开始对话或运行任务', 'Start a chat or run a task')
|
||||
: connection.status == RuntimeConnectionStatus.error
|
||||
: connectionState.status == RuntimeConnectionStatus.error
|
||||
? appText('Gateway 连接失败', 'Gateway connection failed')
|
||||
: appText('先连接 Gateway', 'Connect a gateway first');
|
||||
final description = aiGatewayOnly
|
||||
@ -2244,18 +2242,18 @@ class _AssistantEmptyState extends StatelessWidget {
|
||||
'输入需求后即可开始执行,结果会回到当前会话并同步到任务页。',
|
||||
'Type a request to start execution. Results return to this session and the Tasks page.',
|
||||
)
|
||||
: connection.pairingRequired
|
||||
: connectionState.pairingRequired
|
||||
? appText(
|
||||
'当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request,再重新连接。',
|
||||
'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.',
|
||||
)
|
||||
: connection.gatewayTokenMissing
|
||||
: connectionState.gatewayTokenMissing
|
||||
? appText(
|
||||
'首次连接需要共享 Token;配对完成后可继续使用本机的 device token。',
|
||||
'The first connection requires a shared token; after pairing, this device can continue with its device token.',
|
||||
)
|
||||
: (connection.lastError?.trim().isNotEmpty == true
|
||||
? connection.lastError!.trim()
|
||||
: (connectionState.lastError?.trim().isNotEmpty == true
|
||||
? connectionState.lastError!.trim()
|
||||
: appText(
|
||||
'连接后可直接对话、创建任务,并在当前会话查看结果。',
|
||||
'After connecting, you can chat, create tasks, and read results in this session.',
|
||||
@ -2445,14 +2443,11 @@ class _ComposerBarState extends State<_ComposerBar> {
|
||||
final uiFeatures = controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final aiGatewayOnly = controller.isAiGatewayOnlyMode;
|
||||
final connected = aiGatewayOnly
|
||||
? controller.canUseAiGatewayConversation
|
||||
: controller.connection.status == RuntimeConnectionStatus.connected;
|
||||
final connectionState = controller.currentAssistantConnectionState;
|
||||
final aiGatewayOnly = connectionState.isAiGatewayOnly;
|
||||
final connected = connectionState.connected;
|
||||
final reconnectAvailable = controller.canQuickConnectGateway;
|
||||
final connecting =
|
||||
!aiGatewayOnly &&
|
||||
controller.connection.status == RuntimeConnectionStatus.connecting;
|
||||
final connecting = connectionState.connecting;
|
||||
final executionTarget = controller.assistantExecutionTarget;
|
||||
final permissionLevel = controller.assistantPermissionLevel;
|
||||
final selectedSkills = widget.availableSkills
|
||||
@ -3810,11 +3805,12 @@ class _ConnectionChip extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final connection = controller.connection;
|
||||
final aiGatewayOnly = controller.isAiGatewayOnlyMode;
|
||||
final color = aiGatewayOnly
|
||||
? context.palette.accentMuted
|
||||
: switch (connection.status) {
|
||||
final connectionState = controller.currentAssistantConnectionState;
|
||||
final color = connectionState.isAiGatewayOnly
|
||||
? (connectionState.connected
|
||||
? context.palette.accentMuted
|
||||
: context.palette.surfaceSecondary)
|
||||
: switch (connectionState.status) {
|
||||
RuntimeConnectionStatus.connected => context.palette.accentMuted,
|
||||
RuntimeConnectionStatus.connecting =>
|
||||
context.palette.surfaceSecondary,
|
||||
|
||||
@ -63,6 +63,36 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantThreadConnectionState {
|
||||
const AssistantThreadConnectionState({
|
||||
required this.executionTarget,
|
||||
required this.status,
|
||||
required this.primaryLabel,
|
||||
required this.detailLabel,
|
||||
required this.ready,
|
||||
required this.pairingRequired,
|
||||
required this.gatewayTokenMissing,
|
||||
required this.lastError,
|
||||
});
|
||||
|
||||
final AssistantExecutionTarget executionTarget;
|
||||
final RuntimeConnectionStatus status;
|
||||
final String primaryLabel;
|
||||
final String detailLabel;
|
||||
final bool ready;
|
||||
final bool pairingRequired;
|
||||
final bool gatewayTokenMissing;
|
||||
final String? lastError;
|
||||
|
||||
bool get isAiGatewayOnly =>
|
||||
executionTarget == AssistantExecutionTarget.aiGatewayOnly;
|
||||
|
||||
bool get connected => ready;
|
||||
|
||||
bool get connecting =>
|
||||
!isAiGatewayOnly && status == RuntimeConnectionStatus.connecting;
|
||||
}
|
||||
|
||||
enum AssistantMessageViewMode { rendered, raw }
|
||||
|
||||
extension AssistantMessageViewModeCopy on AssistantMessageViewMode {
|
||||
|
||||
@ -20,6 +20,7 @@ class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
|
||||
final List<GatewayConnectionProfile> connectedProfiles =
|
||||
<GatewayConnectionProfile>[];
|
||||
final Set<RuntimeConnectionMode> _failingModes = <RuntimeConnectionMode>{};
|
||||
int disconnectCount = 0;
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
|
||||
|
||||
@ -39,6 +40,17 @@ class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
String authPasswordOverride = '',
|
||||
}) async {
|
||||
connectedProfiles.add(profile);
|
||||
if (_failingModes.remove(profile.mode)) {
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode)
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Error',
|
||||
remoteAddress: '${profile.host}:${profile.port}',
|
||||
lastError: 'Failed to connect ${profile.mode.name}',
|
||||
);
|
||||
notifyListeners();
|
||||
throw StateError('Failed to connect ${profile.mode.name}');
|
||||
}
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
@ -99,6 +111,10 @@ class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
|
||||
void failNextConnect(RuntimeConnectionMode mode) {
|
||||
_failingModes.add(mode);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeCodexRuntime extends CodexRuntime {
|
||||
@ -353,6 +369,96 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps the thread connection chip aligned with the selected target',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-connection-chip-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
await _deleteDirectoryWithRetry(tempDirectory);
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
defaultModel: 'qwen2.5-coder:latest',
|
||||
gateway: controller.settings.gateway.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
tls: true,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
expect(controller.assistantConnectionStatusLabel, '已连接');
|
||||
expect(controller.assistantConnectionTargetLabel, '127.0.0.1:18789');
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'remote-thread',
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
gateway.failNextConnect(RuntimeConnectionMode.remote);
|
||||
|
||||
await controller.switchSession('remote-thread');
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(controller.assistantConnectionStatusLabel, '错误');
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'gateway.example.com:9443',
|
||||
);
|
||||
expect(
|
||||
controller.currentAssistantConnectionState.lastError,
|
||||
'Failed to connect remote',
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'main',
|
||||
executionTarget: AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
await controller.switchSession('main');
|
||||
|
||||
expect(controller.assistantConnectionStatusLabel, '仅 AI Gateway');
|
||||
expect(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
'qwen2.5-coder:latest · 127.0.0.1:11434',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('AppController persists markdown view mode per thread', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user