Merge branch 'codex/fix-thread-gateway-status'

This commit is contained in:
Haitao Pan 2026-03-22 12:30:18 +08:00
commit 8762d56d5e
5 changed files with 273 additions and 59 deletions

View File

@ -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;

View File

@ -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 {

View File

@ -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,

View File

@ -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 {

View File

@ -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(