refactor: execute stage0-5 workflow and tighten closure guards

This commit is contained in:
Haitao Pan 2026-03-28 19:45:03 +08:00
parent f67d8300de
commit e3760b3638
13 changed files with 2545 additions and 1962 deletions

View File

@ -16,6 +16,10 @@
This section defines the reusable refactor workflow for this repo.
When trigger conditions are met, the workflow is executed by default without additional confirmation prompts.
Normative source:
- Use this section as the single enforcement source for refactor execution rules.
- ADR documents only record decision background and must not introduce conflicting rules.
### Workflow Composition
The standard combines:

View File

@ -0,0 +1,38 @@
# ADR: Refactor Style Baseline Uses No-`part` File Organization
- Status: Accepted
- Date: 2026-03-28
- Owner: XWorkmate maintainers
## Context
The codebase previously mixed multiple split styles (`part`-based and import-based splits), which created unclear review standards and inconsistent refactor outcomes.
The repository has already migrated away from Dart `part` declarations in production/test code, while workflow and skill references still mention both styles.
## Decision
We standardize on **no-`part`** organization for this repository:
- Use import-based closure files and explicit ownership boundaries.
- Keep one business closure per file family (root + closure-owned supporting files).
- Avoid introducing new `part` / `part of` declarations.
## Single Source of Enforcement
`AGENTS.md` section **Refactor Workflow Standard** is the only normative enforcement source.
This ADR is historical rationale and does not define additional runtime enforcement rules.
If this ADR and `AGENTS.md` ever diverge, `AGENTS.md` wins.
## Consequences
- Refactor reviews no longer debate split style; they focus on closure ownership and behavior safety.
- File-size and closure guards should target implementation-bearing files instead of thin export anchors.
- Existing helper files remain valid only when closure-owned; generic helper sprawl is disallowed.
## Verification Checklist
- No new `part` / `part of` usage in `lib/` and `test/`.
- Refactor plans and implementations follow `RED -> GREEN -> REFACTOR -> REGRESSION`.
- Triggered refactor tasks satisfy the `Done Criteria` in `AGENTS.md`.

View File

@ -0,0 +1,28 @@
# Stage 4 Helper Ownership (2026-03-28)
## Scope
Scan command:
```bash
rg --files lib test | rg "(helper|helpers|utils)"
```
Result: no generic `utils` directory/file; helper files are domain-scoped.
## Ownership Mapping
| File | Business closure owner | Status |
|---|---|---|
| `lib/app/app_controller_desktop_runtime_helpers.dart` | Desktop runtime base helpers (streaming text, URL parsing, observer notifications) | Kept, already reduced and scoped |
| `lib/runtime/gateway_runtime_helpers.dart` | Gateway runtime core/helper closure | Kept, domain-owned |
| `lib/runtime/direct_single_agent_app_server_client_helpers.dart` | Single-agent app-server client closure | Kept, domain-owned |
| `lib/app/app_controller_web_helpers.dart` | Web AppController helper closure | Kept, domain-owned |
| `lib/web/web_assistant_page_helpers.dart` | Web assistant page closure | Kept, domain-owned |
| `lib/features/assistant/assistant_page_composer_state_helpers.dart` | Assistant composer state closure | Kept, domain-owned |
## Stage-4 Conclusion
- No cross-domain `utils` bucket was found under `lib/` and `test/`.
- Existing helper files are already tied to explicit business closures.
- Governance decision: continue to allow `*_helpers.dart` only when the file name contains explicit domain ownership (feature/runtime/controller scope), and avoid introducing shared catch-all helpers.

View File

@ -0,0 +1,384 @@
// ignore_for_file: unused_import, unnecessary_import, invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'app_metadata.dart';
import 'app_capabilities.dart';
import 'app_store_policy.dart';
import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
import '../runtime/runtime_controllers.dart';
import '../runtime/runtime_models.dart';
import '../runtime/secure_config_store.dart';
import '../runtime/embedded_agent_launch_policy.dart';
import '../runtime/runtime_coordinator.dart';
import '../runtime/direct_single_agent_app_server_client.dart';
import '../runtime/gateway_acp_client.dart';
import '../runtime/codex_runtime.dart';
import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
import '../runtime/platform_environment.dart';
import '../runtime/single_agent_runner.dart';
import '../runtime/skill_directory_access.dart';
import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_navigation.dart';
import 'app_controller_desktop_gateway.dart';
import 'app_controller_desktop_settings.dart';
import 'app_controller_desktop_single_agent.dart';
import 'app_controller_desktop_thread_sessions.dart';
import 'app_controller_desktop_thread_actions.dart';
import 'app_controller_desktop_workspace_execution.dart';
import 'app_controller_desktop_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart';
Future<void> refreshAcpCapabilitiesRuntimeInternal(
AppController controller, {
bool forceRefresh = false,
bool persistMountTargets = false,
}) async {
GatewayAcpCapabilities capabilities;
try {
capabilities = await controller.gatewayAcpClientInternal.loadCapabilities(
forceRefresh: forceRefresh,
);
} catch (_) {
capabilities = const GatewayAcpCapabilities.empty();
}
if (persistMountTargets && !controller.disposedInternal) {
final currentConfig = controller.settings.multiAgent;
final nextTargets = mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal(
controller,
currentConfig.mountTargets,
capabilities,
);
final nextConfig = currentConfig.copyWith(mountTargets: nextTargets);
if (jsonEncode(nextConfig.toJson()) != jsonEncode(currentConfig.toJson())) {
await controller.settingsControllerInternal.saveSnapshot(
controller.settings.copyWith(multiAgent: nextConfig),
);
controller.multiAgentOrchestratorInternal.updateConfig(nextConfig);
}
}
if (!controller.disposedInternal) {
controller.notifyListeners();
}
}
Future<void> refreshSingleAgentCapabilitiesRuntimeInternal(
AppController controller, {
bool forceRefresh = false,
}) async {
final gatewayToken = await controller.settingsController.loadGatewayToken();
final next = <SingleAgentProvider, DirectSingleAgentCapabilities>{};
for (final provider in controller.configuredSingleAgentProviders) {
final profile = controller.settings.externalAcpEndpointForProvider(provider);
if (!profile.enabled || profile.endpoint.trim().isEmpty) {
next[provider] = const DirectSingleAgentCapabilities.unavailable(
endpoint: '',
);
continue;
}
try {
next[provider] = await controller.singleAgentAppServerClientInternal
.loadCapabilities(
provider: provider,
forceRefresh: forceRefresh,
gatewayToken: gatewayToken,
);
} catch (_) {
next[provider] = const DirectSingleAgentCapabilities.unavailable(
endpoint: '',
);
}
}
controller.singleAgentCapabilitiesByProviderInternal = next;
if (!controller.disposedInternal) {
controller.notifyListeners();
}
}
List<ManagedMountTargetState> mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal(
AppController controller,
List<ManagedMountTargetState> current,
GatewayAcpCapabilities capabilities,
) {
final source = current.isEmpty ? ManagedMountTargetState.defaults() : current;
final providers = capabilities.providers.map((item) => item.providerId).toSet();
return source
.map((item) {
final available = switch (item.targetId) {
'codex' => providers.contains('codex'),
'opencode' => providers.contains('opencode'),
'claude' => providers.contains('claude'),
'gemini' => providers.contains('gemini'),
'aris' => capabilities.multiAgent,
'openclaw' => capabilities.multiAgent || capabilities.singleAgent,
_ => false,
};
return item.copyWith(
available: available,
discoveryState: available ? 'ready' : 'unavailable',
syncState: available ? item.syncState : 'idle',
detail: available
? appText(
'来源Gateway ACP capabilities',
'Source: Gateway ACP capabilities',
)
: appText(
'Gateway ACP 未报告该能力。',
'Gateway ACP did not report this capability.',
),
);
})
.toList(growable: false);
}
String? assistantWorkingDirectoryForSessionRuntimeInternal(
AppController controller,
String sessionKey,
) {
final candidate = controller.assistantWorkspaceRefForSession(sessionKey).trim();
if (candidate.isEmpty) {
return null;
}
return candidate;
}
String? resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
AppController controller,
String sessionKey, {
bool requireLocalExistence = true,
}) {
if (controller.assistantWorkspaceRefKindForSession(sessionKey) !=
WorkspaceRefKind.localPath) {
return null;
}
final candidate = assistantWorkingDirectoryForSessionRuntimeInternal(
controller,
sessionKey,
);
if (candidate == null) {
return null;
}
final directory = Directory(candidate);
if (directory.existsSync()) {
return directory.path;
}
if (requireLocalExistence) {
return null;
}
return candidate;
}
String? resolveSingleAgentWorkingDirectoryForSessionRuntimeInternal(
AppController controller,
String sessionKey, {
SingleAgentProvider? provider,
}) {
final workspaceKind = controller.assistantWorkspaceRefKindForSession(
sessionKey,
);
if (workspaceKind == WorkspaceRefKind.objectStore) {
return null;
}
if (workspaceKind == WorkspaceRefKind.remotePath) {
return assistantWorkingDirectoryForSessionRuntimeInternal(
controller,
sessionKey,
);
}
return resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
controller,
sessionKey,
requireLocalExistence:
provider == null ||
singleAgentProviderRequiresLocalPathRuntimeInternal(controller, provider),
);
}
bool singleAgentProviderRequiresLocalPathRuntimeInternal(
AppController controller,
SingleAgentProvider provider,
) {
final endpoint = resolveSingleAgentEndpointRuntimeInternal(
controller,
provider,
);
if (endpoint == null) {
return true;
}
final scheme = endpoint.scheme.trim().toLowerCase();
if (scheme == 'wss' || scheme == 'https') {
return false;
}
final host = endpoint.host.trim();
if (host.isEmpty) {
return true;
}
final address = InternetAddress.tryParse(host);
if (address != null) {
return !(address.isLoopback || address.type == InternetAddressType.unix);
}
final normalizedHost = host.toLowerCase();
if (normalizedHost == 'localhost') {
return true;
}
return false;
}
CodeAgentNodeState buildCodeAgentNodeStateRuntimeInternal(
AppController controller,
) {
return CodeAgentNodeState(
selectedAgentId: controller.agentsControllerInternal.selectedAgentId,
gatewayConnected: controller.runtimeInternal.isConnected,
executionTarget: controller.currentAssistantExecutionTarget,
runtimeMode: controller.effectiveCodeAgentRuntimeMode,
bridgeEnabled: controller.isCodexBridgeEnabledInternal,
bridgeState: controller.codexCooperationStateInternal.name,
preferredProviderId: 'codex',
resolvedCodexCliPath: controller.resolvedCodexCliPathInternal,
configuredCodexCliPath: controller.configuredCodexCliPath,
);
}
GatewayMode bridgeGatewayModeRuntimeInternal(AppController controller) {
if (!controller.runtimeInternal.isConnected) {
return GatewayMode.offline;
}
return switch (controller.currentAssistantExecutionTarget) {
AssistantExecutionTarget.singleAgent => GatewayMode.offline,
AssistantExecutionTarget.local => GatewayMode.local,
AssistantExecutionTarget.remote => GatewayMode.remote,
};
}
Future<void> ensureCodexGatewayRegistrationRuntimeInternal(
AppController controller,
) async {
if (!controller.isCodexBridgeEnabledInternal) {
return;
}
if (!controller.runtimeInternal.isConnected) {
controller.codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
controller.codeAgentBridgeRegistryInternal.clearRegistration();
controller.notifyListeners();
return;
}
if (controller.codeAgentBridgeRegistryInternal.isRegistered) {
controller.codexCooperationStateInternal = CodexCooperationState.registered;
controller.notifyListeners();
return;
}
try {
final dispatch = controller.codeAgentNodeOrchestratorInternal
.buildGatewayDispatch(buildCodeAgentNodeStateRuntimeInternal(controller));
await controller.codeAgentBridgeRegistryInternal.register(
agentType: 'code-agent-bridge',
name: 'XWorkmate Codex Bridge',
version: kAppVersion,
transport: 'stdio-bridge',
capabilities: const <AgentCapability>[
AgentCapability(
name: 'chat',
description: 'Bridge external Codex CLI chat turns.',
),
AgentCapability(
name: 'code-edit',
description: 'Bridge code editing tasks through Codex CLI.',
),
AgentCapability(
name: 'memory-sync',
description: 'Coordinate memory sync through OpenClaw Gateway.',
),
],
metadata: <String, dynamic>{
...dispatch.metadata,
'providerId': 'codex',
'runtimeMode': controller.effectiveCodeAgentRuntimeMode.name,
'gatewayMode': bridgeGatewayModeRuntimeInternal(controller).name,
'binaryConfigured':
(controller.resolvedCodexCliPath ?? controller.configuredCodexCliPath)
.trim()
.isNotEmpty,
'capabilities': const <String>[
'chat',
'code-edit',
'gateway-bridge',
'memory-sync',
],
},
);
controller.codexCooperationStateInternal = CodexCooperationState.registered;
controller.codexBridgeErrorInternal = null;
} catch (error) {
controller.codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
controller.codexBridgeErrorInternal = error.toString();
}
controller.notifyListeners();
}
void clearCodexGatewayRegistrationRuntimeInternal(AppController controller) {
controller.codeAgentBridgeRegistryInternal.clearRegistration();
if (controller.isCodexBridgeEnabledInternal) {
controller.codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
} else {
controller.codexCooperationStateInternal = CodexCooperationState.notStarted;
}
controller.notifyListeners();
}
void recomputeTasksRuntimeInternal(AppController controller) {
controller.tasksControllerInternal.recompute(
sessions: controller.sessions,
cronJobs: controller.cronJobsControllerInternal.items,
currentSessionKey: controller.sessionsControllerInternal.currentSessionKey,
hasPendingRun: controller.hasAssistantPendingRun,
activeAgentName: controller.agentsControllerInternal.activeAgentName,
);
}
Uri? resolveSingleAgentEndpointRuntimeInternal(
AppController controller,
SingleAgentProvider provider,
) {
final endpoint = controller.settings
.externalAcpEndpointForProvider(provider)
.endpoint
.trim();
if (endpoint.isEmpty) {
return null;
}
final normalizedInput = endpoint.contains('://') ? endpoint : 'ws://$endpoint';
final uri = Uri.tryParse(normalizedInput);
if (uri == null || uri.host.trim().isEmpty) {
return null;
}
final scheme = uri.scheme.trim().toLowerCase();
if (scheme != 'ws' &&
scheme != 'wss' &&
scheme != 'http' &&
scheme != 'https') {
return null;
}
return uri;
}

View File

@ -45,6 +45,7 @@ import 'app_controller_desktop_workspace_execution.dart';
import 'app_controller_desktop_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_runtime_coordination_impl.dart';
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
extension AppControllerDesktopRuntimeHelpers on AppController {
@ -379,64 +380,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
Future<void> refreshAcpCapabilitiesInternal({
bool forceRefresh = false,
bool persistMountTargets = false,
}) async {
GatewayAcpCapabilities capabilities;
try {
capabilities = await gatewayAcpClientInternal.loadCapabilities(
forceRefresh: forceRefresh,
);
} catch (_) {
capabilities = const GatewayAcpCapabilities.empty();
}
if (persistMountTargets && !disposedInternal) {
final currentConfig = settings.multiAgent;
final nextTargets = mergeAcpCapabilitiesIntoMountTargetsInternal(
currentConfig.mountTargets,
capabilities,
);
final nextConfig = currentConfig.copyWith(mountTargets: nextTargets);
if (jsonEncode(nextConfig.toJson()) !=
jsonEncode(currentConfig.toJson())) {
await settingsControllerInternal.saveSnapshot(
settings.copyWith(multiAgent: nextConfig),
);
multiAgentOrchestratorInternal.updateConfig(nextConfig);
}
}
notifyIfActiveInternal();
}
}) => refreshAcpCapabilitiesRuntimeInternal(
this,
forceRefresh: forceRefresh,
persistMountTargets: persistMountTargets,
);
Future<void> refreshSingleAgentCapabilitiesInternal({
bool forceRefresh = false,
}) async {
final gatewayToken = await settingsController.loadGatewayToken();
final next = <SingleAgentProvider, DirectSingleAgentCapabilities>{};
for (final provider in configuredSingleAgentProviders) {
final profile = settings.externalAcpEndpointForProvider(provider);
if (!profile.enabled || profile.endpoint.trim().isEmpty) {
next[provider] = const DirectSingleAgentCapabilities.unavailable(
endpoint: '',
);
continue;
}
try {
next[provider] = await singleAgentAppServerClientInternal
.loadCapabilities(
provider: provider,
forceRefresh: forceRefresh,
gatewayToken: gatewayToken,
);
} catch (_) {
next[provider] = const DirectSingleAgentCapabilities.unavailable(
endpoint: '',
);
}
}
singleAgentCapabilitiesByProviderInternal = next;
if (!disposedInternal) {
notifyIfActiveInternal();
}
}
}) => refreshSingleAgentCapabilitiesRuntimeInternal(
this,
forceRefresh: forceRefresh,
);
Future<void> refreshResolvedCodexCliPathInternal() async {
if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) {
@ -471,116 +426,36 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
List<ManagedMountTargetState> mergeAcpCapabilitiesIntoMountTargetsInternal(
List<ManagedMountTargetState> current,
GatewayAcpCapabilities capabilities,
) {
final source = current.isEmpty
? ManagedMountTargetState.defaults()
: current;
final providers = capabilities.providers
.map((item) => item.providerId)
.toSet();
return source
.map((item) {
final available = switch (item.targetId) {
'codex' => providers.contains('codex'),
'opencode' => providers.contains('opencode'),
'claude' => providers.contains('claude'),
'gemini' => providers.contains('gemini'),
'aris' => capabilities.multiAgent,
'openclaw' => capabilities.multiAgent || capabilities.singleAgent,
_ => false,
};
return item.copyWith(
available: available,
discoveryState: available ? 'ready' : 'unavailable',
syncState: available ? item.syncState : 'idle',
detail: available
? appText(
'来源Gateway ACP capabilities',
'Source: Gateway ACP capabilities',
)
: appText(
'Gateway ACP 未报告该能力。',
'Gateway ACP did not report this capability.',
),
);
})
.toList(growable: false);
}
) => mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal(
this,
current,
capabilities,
);
String? assistantWorkingDirectoryForSessionInternal(String sessionKey) {
final candidate = assistantWorkspaceRefForSession(sessionKey).trim();
if (candidate.isEmpty) {
return null;
}
return candidate;
}
String? assistantWorkingDirectoryForSessionInternal(String sessionKey) =>
assistantWorkingDirectoryForSessionRuntimeInternal(this, sessionKey);
String? resolveLocalAssistantWorkingDirectoryForSessionInternal(
String sessionKey, {
bool requireLocalExistence = true,
}) {
if (assistantWorkspaceRefKindForSession(sessionKey) !=
WorkspaceRefKind.localPath) {
return null;
}
final candidate = assistantWorkingDirectoryForSessionInternal(sessionKey);
if (candidate == null) {
return null;
}
final directory = Directory(candidate);
if (directory.existsSync()) {
return directory.path;
}
if (requireLocalExistence) {
return null;
}
return candidate;
}
}) => resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
this,
sessionKey,
requireLocalExistence: requireLocalExistence,
);
String? resolveSingleAgentWorkingDirectoryForSessionInternal(
String sessionKey, {
SingleAgentProvider? provider,
}) {
final workspaceKind = assistantWorkspaceRefKindForSession(sessionKey);
if (workspaceKind == WorkspaceRefKind.objectStore) {
return null;
}
if (workspaceKind == WorkspaceRefKind.remotePath) {
return assistantWorkingDirectoryForSessionInternal(sessionKey);
}
return resolveLocalAssistantWorkingDirectoryForSessionInternal(
sessionKey,
requireLocalExistence:
provider == null ||
singleAgentProviderRequiresLocalPathInternal(provider),
);
}
}) => resolveSingleAgentWorkingDirectoryForSessionRuntimeInternal(
this,
sessionKey,
provider: provider,
);
bool singleAgentProviderRequiresLocalPathInternal(
SingleAgentProvider provider,
) {
final endpoint = resolveSingleAgentEndpointInternal(provider);
if (endpoint == null) {
return true;
}
final scheme = endpoint.scheme.trim().toLowerCase();
if (scheme == 'wss' || scheme == 'https') {
return false;
}
final host = endpoint.host.trim();
if (host.isEmpty) {
return true;
}
final address = InternetAddress.tryParse(host);
if (address != null) {
return !(address.isLoopback || address.type == InternetAddressType.unix);
}
final normalizedHost = host.toLowerCase();
if (normalizedHost == 'localhost') {
return true;
}
return false;
}
) => singleAgentProviderRequiresLocalPathRuntimeInternal(this, provider);
void registerCodexExternalProviderInternal() {
runtimeCoordinatorInternal.registerExternalCodeAgent(
@ -603,117 +478,19 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
);
}
CodeAgentNodeState buildCodeAgentNodeStateInternal() {
return CodeAgentNodeState(
selectedAgentId: agentsControllerInternal.selectedAgentId,
gatewayConnected: runtimeInternal.isConnected,
executionTarget: currentAssistantExecutionTarget,
runtimeMode: effectiveCodeAgentRuntimeMode,
bridgeEnabled: isCodexBridgeEnabledInternal,
bridgeState: codexCooperationStateInternal.name,
preferredProviderId: 'codex',
resolvedCodexCliPath: resolvedCodexCliPathInternal,
configuredCodexCliPath: configuredCodexCliPath,
);
}
CodeAgentNodeState buildCodeAgentNodeStateInternal() =>
buildCodeAgentNodeStateRuntimeInternal(this);
GatewayMode bridgeGatewayModeInternal() {
if (!runtimeInternal.isConnected) {
return GatewayMode.offline;
}
return switch (currentAssistantExecutionTarget) {
AssistantExecutionTarget.singleAgent => GatewayMode.offline,
AssistantExecutionTarget.local => GatewayMode.local,
AssistantExecutionTarget.remote => GatewayMode.remote,
};
}
GatewayMode bridgeGatewayModeInternal() =>
bridgeGatewayModeRuntimeInternal(this);
Future<void> ensureCodexGatewayRegistrationInternal() async {
if (!isCodexBridgeEnabledInternal) {
return;
}
Future<void> ensureCodexGatewayRegistrationInternal() =>
ensureCodexGatewayRegistrationRuntimeInternal(this);
if (!runtimeInternal.isConnected) {
codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
codeAgentBridgeRegistryInternal.clearRegistration();
notifyListeners();
return;
}
void clearCodexGatewayRegistrationInternal() =>
clearCodexGatewayRegistrationRuntimeInternal(this);
if (codeAgentBridgeRegistryInternal.isRegistered) {
codexCooperationStateInternal = CodexCooperationState.registered;
notifyListeners();
return;
}
try {
final dispatch = codeAgentNodeOrchestratorInternal.buildGatewayDispatch(
buildCodeAgentNodeStateInternal(),
);
await codeAgentBridgeRegistryInternal.register(
agentType: 'code-agent-bridge',
name: 'XWorkmate Codex Bridge',
version: kAppVersion,
transport: 'stdio-bridge',
capabilities: const <AgentCapability>[
AgentCapability(
name: 'chat',
description: 'Bridge external Codex CLI chat turns.',
),
AgentCapability(
name: 'code-edit',
description: 'Bridge code editing tasks through Codex CLI.',
),
AgentCapability(
name: 'memory-sync',
description: 'Coordinate memory sync through OpenClaw Gateway.',
),
],
metadata: <String, dynamic>{
...dispatch.metadata,
'providerId': 'codex',
'runtimeMode': effectiveCodeAgentRuntimeMode.name,
'gatewayMode': bridgeGatewayModeInternal().name,
'binaryConfigured': (resolvedCodexCliPath ?? configuredCodexCliPath)
.trim()
.isNotEmpty,
'capabilities': const <String>[
'chat',
'code-edit',
'gateway-bridge',
'memory-sync',
],
},
);
codexCooperationStateInternal = CodexCooperationState.registered;
codexBridgeErrorInternal = null;
} catch (error) {
codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
codexBridgeErrorInternal = error.toString();
}
notifyListeners();
}
void clearCodexGatewayRegistrationInternal() {
codeAgentBridgeRegistryInternal.clearRegistration();
if (isCodexBridgeEnabledInternal) {
codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
} else {
codexCooperationStateInternal = CodexCooperationState.notStarted;
}
notifyListeners();
}
void recomputeTasksInternal() {
tasksControllerInternal.recompute(
sessions: sessions,
cronJobs: cronJobsControllerInternal.items,
currentSessionKey: sessionsControllerInternal.currentSessionKey,
hasPendingRun: hasAssistantPendingRun,
activeAgentName: agentsControllerInternal.activeAgentName,
);
}
void recomputeTasksInternal() => recomputeTasksRuntimeInternal(this);
void attachChildListenersInternal() {
runtimeCoordinatorInternal.addListener(relayChildChangeInternal);

View File

@ -45,6 +45,7 @@ import 'app_controller_desktop_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_runtime_helpers.dart';
import 'app_controller_desktop_thread_sessions_collaboration_impl.dart';
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
extension AppControllerDesktopThreadSessions on AppController {
@ -430,293 +431,45 @@ extension AppControllerDesktopThreadSessions on AppController {
String get assistantConnectionStatusLabel =>
currentAssistantConnectionState.primaryLabel;
String get assistantConnectionTargetLabel =>
currentAssistantConnectionState.detailLabel;
String get assistantConnectionTargetLabel {
return currentAssistantConnectionState.detailLabel;
}
Future<String> loadAiGatewayApiKey() async {
return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> saveMultiAgentConfig(MultiAgentConfig config) async {
final resolved = resolveMultiAgentConfigInternal(
settings.copyWith(multiAgent: config),
);
await AppControllerDesktopSettings(this).saveSettings(
settings.copyWith(multiAgent: resolved),
refreshAfterSave: false,
);
await refreshMultiAgentMounts(sync: resolved.autoSync);
}
Future<void> refreshMultiAgentMounts({bool sync = false}) async {
await refreshAcpCapabilitiesInternal(persistMountTargets: true);
}
Future<String> loadAiGatewayApiKey() =>
loadAiGatewayApiKeyThreadSessionInternal(this);
Future<void> saveMultiAgentConfig(MultiAgentConfig config) =>
saveMultiAgentConfigThreadSessionInternal(this, config);
Future<void> refreshMultiAgentMounts({bool sync = false}) =>
refreshMultiAgentMountsThreadSessionInternal(this, sync: sync);
Future<void> runMultiAgentCollaboration({
required String rawPrompt,
required String composedPrompt,
required List<CollaborationAttachment> attachments,
required List<String> selectedSkillLabels,
}) async {
final sessionKey = currentSessionKey.trim().isEmpty
? 'main'
: currentSessionKey;
await enqueueThreadTurnInternal<void>(sessionKey, () async {
final aiGatewayApiKey = await loadAiGatewayApiKey();
multiAgentRunPendingInternal = true;
appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'user',
text: rawPrompt,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
recomputeTasksInternal();
try {
final taskStream = gatewayAcpClientInternal.runMultiAgent(
GatewayAcpMultiAgentRequest(
sessionId: sessionKey,
threadId: sessionKey,
prompt: composedPrompt,
workingDirectory:
assistantWorkingDirectoryForSessionInternal(sessionKey) ??
Directory.current.path,
attachments: attachments,
selectedSkills: selectedSkillLabels,
aiGatewayBaseUrl: aiGatewayUrl,
aiGatewayApiKey: aiGatewayApiKey,
resumeSession: true,
),
);
await for (final event in taskStream) {
if (event.type == 'result') {
final success = event.data['success'] == true;
final finalScore = event.data['finalScore'];
final iterations = event.data['iterations'];
appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: success
? appText(
'多 Agent 协作完成,评分 ${finalScore ?? '-'},迭代 ${iterations ?? 0} 次。',
'Multi-agent collaboration completed with score ${finalScore ?? '-'} after ${iterations ?? 0} iteration(s).',
)
: appText(
'多 Agent 协作失败:${event.data['error'] ?? event.message}',
'Multi-agent collaboration failed: ${event.data['error'] ?? event.message}',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: !success,
),
);
continue;
}
appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: event.message,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: event.title,
stopReason: null,
pending: event.pending,
error: event.error,
),
);
}
} on GatewayAcpException catch (error) {
appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: appText(
'多 Agent 协作不可用Gateway ACP${error.message}',
'Multi-agent collaboration is unavailable (Gateway ACP): ${error.message}',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'Multi-Agent',
stopReason: null,
pending: false,
error: true,
),
);
} catch (error) {
appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: nextLocalMessageIdInternal(),
role: 'assistant',
text: error.toString(),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'Multi-Agent',
stopReason: null,
pending: false,
error: true,
),
);
} finally {
multiAgentRunPendingInternal = false;
recomputeTasksInternal();
notifyIfActiveInternal();
}
});
}
Future<void> openOnlineWorkspace() async {
const url = 'https://www.svc.plus/Xworkmate';
try {
if (Platform.isMacOS) {
await Process.run('open', [url]);
return;
}
if (Platform.isWindows) {
await Process.run('cmd', ['/c', 'start', '', url]);
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', [url]);
}
} catch (_) {
// Best effort only. Do not surface a blocking error from a convenience link.
}
}
List<String> get aiGatewayModelChoices {
return aiGatewayConversationModelChoices;
}
List<String> get connectedGatewayModelChoices {
if (connection.status != RuntimeConnectionStatus.connected) {
return const <String>[];
}
return modelsControllerInternal.items
.map((item) => item.id.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
List<String> get assistantModelChoices {
return assistantModelChoicesForSessionInternal(currentSessionKey);
}
List<String> assistantModelChoicesForSessionInternal(String sessionKey) {
final target = assistantExecutionTargetForSession(sessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
return aiGatewayConversationModelChoices;
}
final selectedModel =
assistantThreadRecordsInternal[normalizedAssistantSessionKeyInternal(
sessionKey,
)]
?.assistantModelId
.trim();
if (selectedModel?.isNotEmpty == true) {
return <String>[selectedModel!];
}
return const <String>[];
}
final runtimeModels = connectedGatewayModelChoices;
if (runtimeModels.isNotEmpty) {
return runtimeModels;
}
final resolved = resolvedDefaultModel.trim();
if (resolved.isNotEmpty) {
return <String>[resolved];
}
final localDefault = settings.ollamaLocal.defaultModel.trim();
if (localDefault.isNotEmpty) {
return <String>[localDefault];
}
return const <String>[];
}
String get resolvedDefaultModel {
final current = settings.defaultModel.trim();
if (current.isNotEmpty) {
return current;
}
final localDefault = settings.ollamaLocal.defaultModel.trim();
if (localDefault.isNotEmpty) {
return localDefault;
}
final runtimeModels = connectedGatewayModelChoices;
if (runtimeModels.isNotEmpty) {
return runtimeModels.first;
}
final aiGatewayChoices = aiGatewayConversationModelChoices;
if (aiGatewayChoices.isNotEmpty) {
return aiGatewayChoices.first;
}
return '';
}
bool get canQuickConnectGateway {
final target = currentAssistantExecutionTarget;
if (target == AssistantExecutionTarget.singleAgent) {
return false;
}
final profile = gatewayProfileForAssistantExecutionTargetInternal(target);
if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) {
return true;
}
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return false;
}
if (profile.mode == RuntimeConnectionMode.local) {
return true;
}
final defaults = switch (target) {
AssistantExecutionTarget.singleAgent =>
GatewayConnectionProfile.emptySlot(index: kGatewayRemoteProfileIndex),
AssistantExecutionTarget.local =>
GatewayConnectionProfile.defaultsLocal(),
AssistantExecutionTarget.remote =>
GatewayConnectionProfile.defaultsRemote(),
};
return hasStoredGatewayCredential ||
host != defaults.host ||
profile.port != defaults.port ||
profile.tls != defaults.tls ||
profile.mode != defaults.mode;
}
String joinConnectionPartsInternal(List<String> parts) {
final normalized = parts
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
return normalized.join(' · ');
}
String gatewayAddressLabelInternal(GatewayConnectionProfile profile) {
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return appText('未连接目标', 'No target');
}
return '$host:${profile.port}';
}
}) => runMultiAgentCollaborationThreadSessionInternal(
this,
rawPrompt: rawPrompt,
composedPrompt: composedPrompt,
attachments: attachments,
selectedSkillLabels: selectedSkillLabels,
);
Future<void> openOnlineWorkspace() =>
openOnlineWorkspaceThreadSessionInternal(this);
List<String> get aiGatewayModelChoices =>
aiGatewayModelChoicesThreadSessionInternal(this);
List<String> get connectedGatewayModelChoices =>
connectedGatewayModelChoicesThreadSessionInternal(this);
List<String> get assistantModelChoices =>
assistantModelChoicesThreadSessionInternal(this);
List<String> assistantModelChoicesForSessionInternal(String sessionKey) =>
assistantModelChoicesForSessionThreadSessionInternal(this, sessionKey);
String get resolvedDefaultModel =>
resolvedDefaultModelThreadSessionInternal(this);
bool get canQuickConnectGateway =>
canQuickConnectGatewayThreadSessionInternal(this);
String joinConnectionPartsInternal(List<String> parts) =>
joinConnectionPartsThreadSessionInternal(parts);
String gatewayAddressLabelInternal(GatewayConnectionProfile profile) =>
gatewayAddressLabelThreadSessionInternal(profile);
List<SecretReferenceEntry> get secretReferences =>
settingsControllerInternal.buildSecretReferences();

View File

@ -0,0 +1,381 @@
// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'app_metadata.dart';
import 'app_capabilities.dart';
import 'app_store_policy.dart';
import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
import '../runtime/runtime_controllers.dart';
import '../runtime/runtime_models.dart';
import '../runtime/secure_config_store.dart';
import '../runtime/embedded_agent_launch_policy.dart';
import '../runtime/runtime_coordinator.dart';
import '../runtime/direct_single_agent_app_server_client.dart';
import '../runtime/gateway_acp_client.dart';
import '../runtime/codex_runtime.dart';
import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
import '../runtime/platform_environment.dart';
import '../runtime/single_agent_runner.dart';
import '../runtime/skill_directory_access.dart';
import 'app_controller_desktop_core.dart';
import 'app_controller_desktop_navigation.dart';
import 'app_controller_desktop_gateway.dart';
import 'app_controller_desktop_settings.dart';
import 'app_controller_desktop_single_agent.dart';
import 'app_controller_desktop_thread_actions.dart';
import 'app_controller_desktop_workspace_execution.dart';
import 'app_controller_desktop_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_runtime_helpers.dart';
Future<String> loadAiGatewayApiKeyThreadSessionInternal(
AppController controller,
) async {
return (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> saveMultiAgentConfigThreadSessionInternal(
AppController controller,
MultiAgentConfig config,
) async {
final resolved = controller.resolveMultiAgentConfigInternal(
controller.settings.copyWith(multiAgent: config),
);
await AppControllerDesktopSettings(controller).saveSettings(
controller.settings.copyWith(multiAgent: resolved),
refreshAfterSave: false,
);
await refreshMultiAgentMountsThreadSessionInternal(
controller,
sync: resolved.autoSync,
);
}
Future<void> refreshMultiAgentMountsThreadSessionInternal(
AppController controller, {
bool sync = false,
}) async {
await controller.refreshAcpCapabilitiesInternal(persistMountTargets: true);
}
Future<void> runMultiAgentCollaborationThreadSessionInternal(
AppController controller, {
required String rawPrompt,
required String composedPrompt,
required List<CollaborationAttachment> attachments,
required List<String> selectedSkillLabels,
}) async {
final sessionKey = controller.currentSessionKey.trim().isEmpty
? 'main'
: controller.currentSessionKey;
await controller.enqueueThreadTurnInternal<void>(sessionKey, () async {
final aiGatewayApiKey = await loadAiGatewayApiKeyThreadSessionInternal(
controller,
);
controller.multiAgentRunPendingInternal = true;
controller.appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'user',
text: rawPrompt,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
controller.recomputeTasksInternal();
try {
final taskStream = controller.gatewayAcpClientInternal.runMultiAgent(
GatewayAcpMultiAgentRequest(
sessionId: sessionKey,
threadId: sessionKey,
prompt: composedPrompt,
workingDirectory:
controller.assistantWorkingDirectoryForSessionInternal(
sessionKey,
) ??
Directory.current.path,
attachments: attachments,
selectedSkills: selectedSkillLabels,
aiGatewayBaseUrl: controller.aiGatewayUrl,
aiGatewayApiKey: aiGatewayApiKey,
resumeSession: true,
),
);
await for (final event in taskStream) {
if (event.type == 'result') {
final success = event.data['success'] == true;
final finalScore = event.data['finalScore'];
final iterations = event.data['iterations'];
controller.appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: success
? appText(
'多 Agent 协作完成,评分 ${finalScore ?? '-'},迭代 ${iterations ?? 0} 次。',
'Multi-agent collaboration completed with score ${finalScore ?? '-'} after ${iterations ?? 0} iteration(s).',
)
: appText(
'多 Agent 协作失败:${event.data['error'] ?? event.message}',
'Multi-agent collaboration failed: ${event.data['error'] ?? event.message}',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: !success,
),
);
continue;
}
controller.appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: event.message,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: event.title,
stopReason: null,
pending: event.pending,
error: event.error,
),
);
}
} on GatewayAcpException catch (error) {
controller.appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: appText(
'多 Agent 协作不可用Gateway ACP${error.message}',
'Multi-agent collaboration is unavailable (Gateway ACP): ${error.message}',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'Multi-Agent',
stopReason: null,
pending: false,
error: true,
),
);
} catch (error) {
controller.appendLocalSessionMessageInternal(
sessionKey,
GatewayChatMessage(
id: controller.nextLocalMessageIdInternal(),
role: 'assistant',
text: error.toString(),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'Multi-Agent',
stopReason: null,
pending: false,
error: true,
),
);
} finally {
controller.multiAgentRunPendingInternal = false;
controller.recomputeTasksInternal();
controller.notifyIfActiveInternal();
}
});
}
Future<void> openOnlineWorkspaceThreadSessionInternal(
AppController controller,
) async {
const url = 'https://www.svc.plus/Xworkmate';
try {
if (Platform.isMacOS) {
await Process.run('open', [url]);
return;
}
if (Platform.isWindows) {
await Process.run('cmd', ['/c', 'start', '', url]);
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', [url]);
}
} catch (_) {
// Best effort only. Do not surface a blocking error from a convenience link.
}
}
List<String> aiGatewayModelChoicesThreadSessionInternal(
AppController controller,
) {
return controller.aiGatewayConversationModelChoices;
}
List<String> connectedGatewayModelChoicesThreadSessionInternal(
AppController controller,
) {
if (controller.connection.status != RuntimeConnectionStatus.connected) {
return const <String>[];
}
return controller.modelsControllerInternal.items
.map((item) => item.id.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
List<String> assistantModelChoicesThreadSessionInternal(
AppController controller,
) {
return assistantModelChoicesForSessionThreadSessionInternal(
controller,
controller.currentSessionKey,
);
}
List<String> assistantModelChoicesForSessionThreadSessionInternal(
AppController controller,
String sessionKey,
) {
final normalizedSessionKey = normalizeAssistantSessionKeyThreadInternal(
sessionKey,
);
final target = controller.sanitizeExecutionTargetInternal(
controller.assistantThreadRecordsInternal[normalizedSessionKey]
?.executionTarget ??
controller.settings.assistantExecutionTarget,
);
if (target == AssistantExecutionTarget.singleAgent) {
final singleAgentUsesAiGatewayFallback =
!controller.hasAnyAvailableSingleAgentProvider &&
controller.canUseAiGatewayConversation;
if (singleAgentUsesAiGatewayFallback) {
return controller.aiGatewayConversationModelChoices;
}
final selectedModel =
controller
.assistantThreadRecordsInternal[normalizedSessionKey]
?.assistantModelId
.trim();
if (selectedModel?.isNotEmpty == true) {
return <String>[selectedModel!];
}
return const <String>[];
}
final runtimeModels = connectedGatewayModelChoicesThreadSessionInternal(
controller,
);
if (runtimeModels.isNotEmpty) {
return runtimeModels;
}
final resolved = resolvedDefaultModelThreadSessionInternal(controller).trim();
if (resolved.isNotEmpty) {
return <String>[resolved];
}
final localDefault = controller.settings.ollamaLocal.defaultModel.trim();
if (localDefault.isNotEmpty) {
return <String>[localDefault];
}
return const <String>[];
}
String resolvedDefaultModelThreadSessionInternal(AppController controller) {
final current = controller.settings.defaultModel.trim();
if (current.isNotEmpty) {
return current;
}
final localDefault = controller.settings.ollamaLocal.defaultModel.trim();
if (localDefault.isNotEmpty) {
return localDefault;
}
final runtimeModels = connectedGatewayModelChoicesThreadSessionInternal(
controller,
);
if (runtimeModels.isNotEmpty) {
return runtimeModels.first;
}
final aiGatewayChoices = controller.aiGatewayConversationModelChoices;
if (aiGatewayChoices.isNotEmpty) {
return aiGatewayChoices.first;
}
return '';
}
bool canQuickConnectGatewayThreadSessionInternal(AppController controller) {
final target = controller.currentAssistantExecutionTarget;
if (target == AssistantExecutionTarget.singleAgent) {
return false;
}
final profile = controller.gatewayProfileForAssistantExecutionTargetInternal(
target,
);
if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) {
return true;
}
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return false;
}
if (profile.mode == RuntimeConnectionMode.local) {
return true;
}
final defaults = switch (target) {
AssistantExecutionTarget.singleAgent =>
GatewayConnectionProfile.emptySlot(index: kGatewayRemoteProfileIndex),
AssistantExecutionTarget.local => GatewayConnectionProfile.defaultsLocal(),
AssistantExecutionTarget.remote =>
GatewayConnectionProfile.defaultsRemote(),
};
return controller.hasStoredGatewayCredential ||
host != defaults.host ||
profile.port != defaults.port ||
profile.tls != defaults.tls ||
profile.mode != defaults.mode;
}
String normalizeAssistantSessionKeyThreadInternal(String sessionKey) {
final trimmed = sessionKey.trim();
return trimmed.isEmpty ? 'main' : trimmed;
}
String joinConnectionPartsThreadSessionInternal(List<String> parts) {
final normalized = parts
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
return normalized.join(' · ');
}
String gatewayAddressLabelThreadSessionInternal(
GatewayConnectionProfile profile,
) {
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return appText('未连接目标', 'No target');
}
return '$host:${profile.port}';
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,870 @@
// ignore_for_file: unused_import, unnecessary_import, invalid_use_of_protected_member
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:path_provider/path_provider.dart';
import 'package:super_clipboard/super_clipboard.dart';
import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/multi_agent_orchestrator.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/assistant_focus_panel.dart';
import '../../widgets/assistant_artifact_sidebar.dart';
import '../../widgets/desktop_workspace_scaffold.dart';
import '../../widgets/pane_resize_handle.dart';
import '../../widgets/surface_card.dart';
import 'assistant_page_main.dart';
import 'assistant_page_components.dart';
import 'assistant_page_composer_bar.dart';
import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';
extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
Future<void> pickAttachmentsInternal() async {
final uiFeatures = widget.controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
if (!uiFeatures.supportsFileAttachments) {
return;
}
final files = await openFiles(
acceptedTypeGroups: const [
XTypeGroup(
label: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
),
XTypeGroup(label: 'Logs', extensions: ['log', 'txt', 'json', 'csv']),
XTypeGroup(
label: 'Files',
extensions: ['md', 'pdf', 'yaml', 'yml', 'zip'],
),
],
);
if (!mounted || files.isEmpty) {
return;
}
setState(() {
attachmentsInternal = [
...attachmentsInternal,
...files.map(ComposerAttachmentInternal.fromXFile),
];
});
}
Future<void> submitPromptInternal() async {
final controller = widget.controller;
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final settings = controller.settings;
final executionTarget = controller.assistantExecutionTarget;
final rawPrompt = inputControllerInternal.text.trim();
if (rawPrompt.isEmpty) {
return;
}
final shouldUseGatewayAgent =
executionTarget != AssistantExecutionTarget.singleAgent;
final autoAgent = shouldUseGatewayAgent
? pickAutoAgentInternal(controller, rawPrompt)
: null;
if (autoAgent != null) {
await controller.selectAgent(autoAgent.id);
}
final submittedAttachments = List<ComposerAttachmentInternal>.from(
attachmentsInternal,
growable: false,
);
final attachmentNames = submittedAttachments
.map((item) => item.name)
.toList(growable: false);
final selectedSkillLabels = resolveSelectedSkillLabelsInternal(controller);
final connectionState = controller.currentAssistantConnectionState;
final prompt = composePromptInternal(
mode: modeInternal,
prompt: rawPrompt,
attachmentNames: attachmentNames,
selectedSkillLabels: selectedSkillLabels,
executionTarget: executionTarget,
singleAgentProvider: controller.currentSingleAgentProvider,
permissionLevel: settings.assistantPermissionLevel,
workspacePath: controller.assistantWorkspaceRefForSession(
controller.currentSessionKey,
),
);
setState(() {
lastAutoAgentLabelInternal =
autoAgent?.name ?? conversationOwnerLabelInternal(controller);
attachmentsInternal = const <ComposerAttachmentInternal>[];
touchTaskSeedInternal(
sessionKey: controller.currentSessionKey,
title:
taskSeedsInternal[controller.currentSessionKey]?.title ??
fallbackSessionTitleInternal(controller.currentSessionKey),
preview: rawPrompt,
status:
controller.hasAssistantPendingRun ||
executionTarget == AssistantExecutionTarget.singleAgent ||
connectionState.connected
? 'running'
: 'queued',
owner: autoAgent?.name ?? conversationOwnerLabelInternal(controller),
surface: 'Assistant',
executionTarget: executionTarget,
draft: controller.currentSessionKey.trim().startsWith('draft:'),
);
});
inputControllerInternal.clear();
try {
if (uiFeatures.supportsMultiAgent &&
controller.settings.multiAgent.enabled) {
final collaborationAttachments = submittedAttachments
.map(
(item) => CollaborationAttachment(
name: item.name,
description: item.mimeType,
path: item.path,
),
)
.toList(growable: false);
await controller.runMultiAgentCollaboration(
rawPrompt: rawPrompt,
composedPrompt: prompt,
attachments: collaborationAttachments,
selectedSkillLabels: selectedSkillLabels,
);
} else {
final attachmentPayloads = await buildAttachmentPayloadsInternal(
submittedAttachments,
);
await controller.sendChatMessage(
prompt,
thinking: thinkingLabelInternal,
attachments: attachmentPayloads,
localAttachments: submittedAttachments
.map(
(item) => CollaborationAttachment(
name: item.name,
description: item.mimeType,
path: item.path,
),
)
.toList(growable: false),
selectedSkillLabels: selectedSkillLabels,
);
}
} catch (_) {
if (!mounted) {
rethrow;
}
if (inputControllerInternal.text.trim().isEmpty) {
inputControllerInternal.value = TextEditingValue(
text: rawPrompt,
selection: TextSelection.collapsed(offset: rawPrompt.length),
);
}
if (attachmentsInternal.isEmpty && submittedAttachments.isNotEmpty) {
setState(() {
attachmentsInternal = submittedAttachments;
});
}
rethrow;
}
}
Future<List<GatewayChatAttachmentPayload>> buildAttachmentPayloadsInternal(
List<ComposerAttachmentInternal> attachments,
) async {
final payloads = <GatewayChatAttachmentPayload>[];
for (final attachment in attachments) {
final file = File(attachment.path);
if (!await file.exists()) {
continue;
}
final bytes = await file.readAsBytes();
final mimeType = attachment.mimeType;
payloads.add(
GatewayChatAttachmentPayload(
type: mimeType.startsWith('image/') ? 'image' : 'file',
mimeType: mimeType,
fileName: attachment.name,
content: base64Encode(bytes),
),
);
}
return payloads;
}
GatewayAgentSummary? pickAutoAgentInternal(
AppController controller,
String prompt,
) {
final text = prompt.toLowerCase();
final agents = controller.agents;
if (agents.isEmpty) {
return null;
}
GatewayAgentSummary? byName(String name) {
for (final agent in agents) {
if (agent.name.toLowerCase().contains(name)) {
return agent;
}
}
return null;
}
if (text.contains('browser') ||
text.contains('search') ||
text.contains('website') ||
text.contains('网页') ||
text.contains('') ||
text.contains('抓取')) {
return byName('browser');
}
if (text.contains('research') ||
text.contains('analyze') ||
text.contains('compare') ||
text.contains('summary') ||
text.contains('研究') ||
text.contains('分析') ||
text.contains('调研')) {
return byName('research');
}
if (text.contains('code') ||
text.contains('deploy') ||
text.contains('build') ||
text.contains('test') ||
text.contains('log') ||
text.contains('bug') ||
text.contains('代码') ||
text.contains('部署') ||
text.contains('日志')) {
return byName('coding');
}
return byName('coding') ?? byName('browser') ?? byName('research');
}
List<ComposerSkillOptionInternal> availableSkillOptionsInternal(
AppController controller,
) {
if (controller.isSingleAgentMode) {
return controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map(skillOptionFromThreadSkillInternal)
.toList(growable: false);
}
final options = <ComposerSkillOptionInternal>[];
final seenKeys = <String>{};
void addOption(ComposerSkillOptionInternal option) {
if (seenKeys.add(option.key)) {
options.add(option);
}
}
for (final skill in controller.skills) {
final option = skillOptionFromGatewayInternal(skill);
addOption(option);
}
for (final option in fallbackSkillOptionsInternal) {
addOption(option);
}
return options;
}
List<String> selectedSkillKeysForInternal(AppController controller) {
return controller.assistantSelectedSkillKeysForSession(
controller.currentSessionKey,
);
}
List<String> resolveSelectedSkillLabelsInternal(AppController controller) {
final optionsByKey = <String, ComposerSkillOptionInternal>{
for (final option in availableSkillOptionsInternal(controller))
option.key: option,
};
return selectedSkillKeysForInternal(controller)
.map((key) => optionsByKey[key]?.label)
.whereType<String>()
.toList(growable: false);
}
String composePromptInternal({
required String mode,
required String prompt,
required List<String> attachmentNames,
required List<String> selectedSkillLabels,
required AssistantExecutionTarget executionTarget,
required SingleAgentProvider singleAgentProvider,
required AssistantPermissionLevel permissionLevel,
required String workspacePath,
}) {
final attachmentBlock = attachmentNames.isEmpty
? ''
: 'Attached files:\n${attachmentNames.map((name) => '- $name').join('\n')}\n\n';
final skillBlock = selectedSkillLabels.isEmpty
? ''
: 'Preferred skills:\n${selectedSkillLabels.map((name) => '- $name').join('\n')}\n\n';
final targetRoot = workspacePath.trim();
final executionContext =
'Execution context:\n'
'- target: ${executionTarget.promptValue}\n'
'${executionTarget == AssistantExecutionTarget.singleAgent ? '- provider: ${singleAgentProvider.providerId}\n' : ''}'
'- workspace_root: ${targetRoot.isEmpty ? 'not-set' : targetRoot}\n'
'- permission: ${permissionLevel.promptValue}\n\n';
return switch (mode) {
'craft' =>
'$attachmentBlock$skillBlock$executionContext'
'Craft a polished result for this request:\n$prompt',
'plan' =>
'$attachmentBlock$skillBlock$executionContext'
'Create a clear execution plan for this task:\n$prompt',
_ => '$attachmentBlock$skillBlock$executionContext$prompt',
};
}
void openGatewaySettingsInternal() {
widget.controller.openSettings(
detail: SettingsDetailPage.gatewayConnection,
navigationContext: SettingsNavigationContext(
rootLabel: appText('助手', 'Assistant'),
destination: WorkspaceDestination.assistant,
sectionLabel: appText('集成', 'Integrations'),
),
);
}
Future<void> connectFromSavedSettingsOrShowDialogInternal() async {
if (!widget.controller.canQuickConnectGateway) {
openGatewaySettingsInternal();
return;
}
await widget.controller.connectSavedGateway();
}
void openAiGatewaySettingsInternal() {
widget.controller.openSettings(tab: SettingsTab.gateway);
}
void focusComposerInternal() {
if (!mounted) {
return;
}
composerFocusNodeInternal.requestFocus();
}
Future<bool> runTaskSessionActionWithRetryInternal(
String label,
Future<void> Function() action,
) async {
Object? lastError;
for (
var attempt = 1;
attempt <= assistantTaskActionMaxRetryCountInternal;
attempt++
) {
try {
await action();
return true;
} catch (error) {
lastError = error;
if (attempt >= assistantTaskActionMaxRetryCountInternal) {
break;
}
await Future<void>.delayed(Duration(milliseconds: 240 * attempt));
}
}
if (!mounted) {
return false;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText(
'$label 失败,弱网环境下已重试 $assistantTaskActionMaxRetryCountInternal 次。',
'$label failed after $assistantTaskActionMaxRetryCountInternal retries on a weak network.',
),
),
),
);
debugPrint('$label failed after retries: $lastError');
return false;
}
Future<void> refreshTasksWithRetryInternal() async {
await runTaskSessionActionWithRetryInternal(
appText('刷新任务列表', 'Refresh task list'),
widget.controller.refreshSessions,
);
}
Future<void> switchSessionWithRetryInternal(String sessionKey) async {
final switched = await runTaskSessionActionWithRetryInternal(
appText('切换会话', 'Switch session'),
() => widget.controller.switchSession(sessionKey),
);
if (switched) {
focusComposerInternal();
}
}
Future<void> createNewThreadInternal() async {
final sessionKey = buildDraftSessionKeyInternal(widget.controller);
final inheritedTarget = widget.controller.currentAssistantExecutionTarget;
final inheritedViewMode = widget.controller.currentAssistantMessageViewMode;
setState(() {
archivedTaskKeysInternal.removeWhere(
(value) => sessionKeysMatchInternal(value, sessionKey),
);
taskSeedsInternal[sessionKey] = AssistantTaskSeedInternal(
sessionKey: sessionKey,
title: appText('新对话', 'New conversation'),
preview: appText(
'等待描述这个任务的第一条消息',
'Waiting for the first message of this task',
),
status: 'queued',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: conversationOwnerLabelInternal(widget.controller),
surface: 'Assistant',
executionTarget: inheritedTarget,
draft: true,
);
});
widget.controller.initializeAssistantThreadContext(
sessionKey,
title: appText('新对话', 'New conversation'),
executionTarget: inheritedTarget,
messageViewMode: inheritedViewMode,
singleAgentProvider: widget.controller.currentSingleAgentProvider,
);
await switchSessionWithRetryInternal(sessionKey);
}
List<AssistantTaskEntryInternal> buildTaskEntriesInternal(
AppController controller,
) {
archivedTaskKeysInternal
..clear()
..addAll(controller.settings.assistantArchivedTaskKeys);
synchronizeTaskSeedsInternal(controller);
final entries =
taskSeedsInternal.values
.where((item) => !isArchivedTaskInternal(item.sessionKey))
.map((item) {
final isCurrent = sessionKeysMatchInternal(
item.sessionKey,
controller.currentSessionKey,
);
final entry = item.toEntry(isCurrent: isCurrent);
if (!isCurrent) {
return entry;
}
return entry.copyWith(
owner: conversationOwnerLabelInternal(controller),
);
})
.toList(growable: true)
..sort((left, right) {
if (left.isCurrent != right.isCurrent) {
return left.isCurrent ? -1 : 1;
}
return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0);
});
return entries;
}
List<AssistantTaskEntryInternal> filterTasksInternal(
List<AssistantTaskEntryInternal> items,
) {
final query = threadQueryInternal.trim().toLowerCase();
if (query.isEmpty) {
return items;
}
return items
.where((item) {
final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}'
.toLowerCase();
return haystack.contains(query);
})
.toList(growable: false);
}
AssistantTaskEntryInternal resolveCurrentTaskInternal(
List<AssistantTaskEntryInternal> items,
String sessionKey,
) {
for (final item in items) {
if (sessionKeysMatchInternal(item.sessionKey, sessionKey)) {
return item;
}
}
return AssistantTaskEntryInternal(
sessionKey: sessionKey,
title: resolvedTaskTitleInternal(widget.controller, sessionKey),
preview: '',
status: 'queued',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: conversationOwnerLabelInternal(widget.controller),
surface: 'Assistant',
executionTarget: widget.controller.currentAssistantExecutionTarget,
isCurrent: true,
draft: true,
);
}
void synchronizeTaskSeedsInternal(AppController controller) {
for (final session in controller.assistantSessions) {
if (isArchivedTaskInternal(session.key)) {
continue;
}
taskSeedsInternal[session.key] = AssistantTaskSeedInternal(
sessionKey: session.key,
title: resolvedTaskTitleInternal(
controller,
session.key,
session: session,
),
preview:
sessionPreviewInternal(session) ??
appText('等待继续执行这个任务', 'Waiting to continue this task'),
status: sessionStatusInternal(
session,
sessionPending: controller.assistantSessionHasPendingRun(session.key),
),
updatedAtMs:
session.updatedAtMs ??
DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: conversationOwnerLabelInternal(controller),
surface: session.surface ?? session.kind ?? 'Assistant',
executionTarget: controller.assistantExecutionTargetForSession(
session.key,
),
draft: session.key.trim().startsWith('draft:'),
);
}
final currentSeed = taskSeedsInternal[controller.currentSessionKey];
final currentPreview = currentTaskPreviewInternal(controller.chatMessages);
final currentStatus = currentTaskStatusInternal(
controller.chatMessages,
controller,
);
if (isArchivedTaskInternal(controller.currentSessionKey)) {
return;
}
taskSeedsInternal[controller.currentSessionKey] = AssistantTaskSeedInternal(
sessionKey: controller.currentSessionKey,
title: resolvedTaskTitleInternal(
controller,
controller.currentSessionKey,
fallbackTitle: currentSeed?.title,
),
preview:
currentPreview ??
currentSeed?.preview ??
appText(
'等待描述这个任务的第一条消息',
'Waiting for the first message of this task',
),
status: currentStatus ?? currentSeed?.status ?? 'queued',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: conversationOwnerLabelInternal(controller),
surface: currentSeed?.surface ?? 'Assistant',
executionTarget: controller.assistantExecutionTargetForSession(
controller.currentSessionKey,
),
draft: controller.currentSessionKey.trim().startsWith('draft:'),
);
}
GatewaySessionSummary? sessionByKeyInternal(
AppController controller,
String sessionKey,
) {
for (final session in controller.assistantSessions) {
if (sessionKeysMatchInternal(session.key, sessionKey)) {
return session;
}
}
return null;
}
String resolvedTaskTitleInternal(
AppController controller,
String sessionKey, {
GatewaySessionSummary? session,
String? fallbackTitle,
}) {
final customTitle = controller.assistantCustomTaskTitle(sessionKey);
if (customTitle.isNotEmpty) {
return customTitle;
}
final resolvedSession =
session ?? sessionByKeyInternal(controller, sessionKey);
if (resolvedSession != null) {
return sessionDisplayTitleInternal(resolvedSession);
}
final fallback = fallbackTitle?.trim() ?? '';
if (fallback.isNotEmpty) {
return fallback;
}
return fallbackSessionTitleInternal(sessionKey);
}
String defaultTaskTitleInternal(
AppController controller,
String sessionKey, {
GatewaySessionSummary? session,
}) {
final resolvedSession =
session ?? sessionByKeyInternal(controller, sessionKey);
if (resolvedSession != null) {
return sessionDisplayTitleInternal(resolvedSession);
}
return fallbackSessionTitleInternal(sessionKey);
}
void touchTaskSeedInternal({
required String sessionKey,
required String title,
required String preview,
required String status,
required String owner,
required String surface,
required AssistantExecutionTarget executionTarget,
required bool draft,
}) {
taskSeedsInternal[sessionKey] = AssistantTaskSeedInternal(
sessionKey: sessionKey,
title: title,
preview: preview,
status: status,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: owner,
surface: surface,
executionTarget: executionTarget,
draft: draft,
);
}
bool isArchivedTaskInternal(String sessionKey) {
for (final archivedKey in archivedTaskKeysInternal) {
if (sessionKeysMatchInternal(archivedKey, sessionKey)) {
return true;
}
}
return false;
}
Future<void> archiveTaskInternal(String sessionKey) async {
final isCurrent = sessionKeysMatchInternal(
sessionKey,
widget.controller.currentSessionKey,
);
if (widget.controller.assistantSessionHasPendingRun(sessionKey)) {
return;
}
final archived = await runTaskSessionActionWithRetryInternal(
appText('归档任务', 'Archive task'),
() => widget.controller.saveAssistantTaskArchived(sessionKey, true),
);
if (!archived) {
return;
}
setState(() {
archivedTaskKeysInternal.add(sessionKey);
taskSeedsInternal.removeWhere(
(key, _) => sessionKeysMatchInternal(key, sessionKey),
);
});
if (!isCurrent) {
return;
}
for (final candidate in taskSeedsInternal.keys) {
if (isArchivedTaskInternal(candidate) ||
sessionKeysMatchInternal(candidate, sessionKey)) {
continue;
}
await switchSessionWithRetryInternal(candidate);
return;
}
await createNewThreadInternal();
}
Future<void> renameTaskInternal(AssistantTaskEntryInternal entry) async {
final controller = widget.controller;
final input = TextEditingController(text: entry.title);
final renamed = await showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(appText('重命名任务', 'Rename task')),
content: TextField(
key: const Key('assistant-task-rename-input'),
controller: input,
autofocus: true,
maxLines: 1,
decoration: InputDecoration(
labelText: appText('任务名称', 'Task name'),
hintText: appText(
'留空后恢复默认名称',
'Leave empty to restore the default title',
),
),
onSubmitted: (value) => Navigator.of(context).pop(value),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(input.text),
child: Text(appText('保存', 'Save')),
),
],
);
},
);
if (!mounted || renamed == null) {
return;
}
final normalized = renamed.trim();
final nextTitle = normalized.isNotEmpty
? normalized
: defaultTaskTitleInternal(controller, entry.sessionKey);
final saved = await runTaskSessionActionWithRetryInternal(
appText('重命名任务', 'Rename task'),
() => controller.saveAssistantTaskTitle(entry.sessionKey, normalized),
);
if (!saved) {
return;
}
setState(() {
final existing = taskSeedsInternal[entry.sessionKey];
if (existing != null) {
taskSeedsInternal[entry.sessionKey] = AssistantTaskSeedInternal(
sessionKey: existing.sessionKey,
title: nextTitle,
preview: existing.preview,
status: existing.status,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: existing.owner,
surface: existing.surface,
executionTarget: existing.executionTarget,
draft: existing.draft,
);
}
});
}
String buildDraftSessionKeyInternal(AppController controller) {
final stamp = DateTime.now().millisecondsSinceEpoch;
if (controller.isSingleAgentMode) {
return 'draft:$stamp';
}
final selectedAgentId = controller.selectedAgentId.trim();
if (selectedAgentId.isEmpty) {
return 'draft:$stamp';
}
return 'draft:$selectedAgentId:$stamp';
}
AssistantFocusEntry? resolveFocusedDestinationInternal(
List<AssistantFocusEntry> favorites,
) {
if (favorites.isEmpty) {
return null;
}
if (activeFocusedDestinationInternal != null &&
favorites.contains(activeFocusedDestinationInternal)) {
return activeFocusedDestinationInternal;
}
return favorites.first;
}
double resolveMaxSidePaneWidthInternal(double viewportWidth) {
final maxWidthByViewport =
viewportWidth -
AssistantPageStateInternal.mainWorkspaceMinWidthInternal -
AssistantPageStateInternal.sidePaneViewportPaddingInternal -
assistantHorizontalResizeHandleWidthInternal -
assistantHorizontalPaneGapInternal;
return maxWidthByViewport
.clamp(
AssistantPageStateInternal.sidePaneMinWidthInternal,
viewportWidth -
AssistantPageStateInternal.sidePaneViewportPaddingInternal,
)
.toDouble();
}
String conversationOwnerLabelInternal(AppController controller) {
return controller.assistantConversationOwnerLabel;
}
String? currentTaskPreviewInternal(List<GatewayChatMessage> messages) {
for (final message in messages.reversed) {
final text = message.text.trim();
if (text.isNotEmpty) {
return text;
}
}
return null;
}
String? currentTaskStatusInternal(
List<GatewayChatMessage> messages,
AppController controller,
) {
if (controller.hasAssistantPendingRun) {
return 'running';
}
if (messages.isEmpty) {
return null;
}
final last = messages.last;
if (last.error) {
return 'failed';
}
if (last.role.trim().toLowerCase() == 'user') {
return 'queued';
}
return 'open';
}
}

View File

@ -0,0 +1,430 @@
// ignore_for_file: unused_import, unnecessary_import, invalid_use_of_protected_member
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:path_provider/path_provider.dart';
import 'package:super_clipboard/super_clipboard.dart';
import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/multi_agent_orchestrator.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/assistant_focus_panel.dart';
import '../../widgets/assistant_artifact_sidebar.dart';
import '../../widgets/desktop_workspace_scaffold.dart';
import '../../widgets/pane_resize_handle.dart';
import '../../widgets/surface_card.dart';
import 'assistant_page_main.dart';
import 'assistant_page_components.dart';
import 'assistant_page_composer_bar.dart';
import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';
import 'assistant_page_state_actions.dart';
extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
Widget buildMainWorkspaceInternal({
required AppController controller,
required List<TimelineItemInternal> timelineItems,
required AssistantTaskEntryInternal currentTask,
}) {
return LayoutBuilder(
builder: (context, constraints) {
final palette = context.palette;
final mediaQuery = MediaQuery.of(context);
final composerBottomInset = math.max(
mediaQuery.viewPadding.bottom,
mediaQuery.viewInsets.bottom,
);
final composerBottomSpacing = composerBottomInset > 0
? composerBottomInset + assistantComposerSafeAreaGapInternal
: assistantComposerSafeAreaGapInternal;
final baseComposerHeight = constraints.maxHeight >= 900
? assistantComposerBaseHeightTallInternal
: assistantComposerBaseHeightCompactInternal;
final composerContentWidth = math.max(240.0, constraints.maxWidth - 32);
final availableWorkspaceHeight = math.max(
0.0,
constraints.maxHeight - assistantVerticalResizeHandleHeightInternal,
);
final attachmentExtraHeight =
estimatedComposerWrapSectionHeightInternal(
itemCount: attachmentsInternal.length,
availableWidth: composerContentWidth,
averageChipWidth: 168,
);
final selectedSkillExtraHeight =
estimatedComposerWrapSectionHeightInternal(
itemCount: AssistantPageStateActionsInternal(
this,
).selectedSkillKeysForInternal(controller).length,
availableWidth: composerContentWidth,
averageChipWidth: 132,
);
final fallbackComposerContentHeight =
baseComposerHeight +
math.max(
0.0,
composerInputHeightInternal -
assistantComposerDefaultInputHeightInternal,
) +
attachmentExtraHeight +
selectedSkillExtraHeight;
final composerContentHeight = composerMeasuredContentHeightInternal > 0
? composerMeasuredContentHeightInternal
: fallbackComposerContentHeight;
final defaultComposerHeight = math.min(
availableWorkspaceHeight,
composerContentHeight + composerBottomSpacing,
);
final composerHeightUpperBound = math.min(
availableWorkspaceHeight,
math.max(
assistantWorkspaceMinLowerPaneHeightInternal +
composerBottomSpacing,
availableWorkspaceHeight -
assistantWorkspaceMinConversationHeightInternal,
),
);
final composerHeightLowerBound = math.min(
assistantWorkspaceMinLowerPaneHeightInternal + composerBottomSpacing,
composerHeightUpperBound,
);
final composerHeight =
(defaultComposerHeight + workspaceLowerPaneHeightAdjustmentInternal)
.clamp(composerHeightLowerBound, composerHeightUpperBound)
.toDouble();
return SurfaceCard(
borderRadius: 0,
padding: EdgeInsets.zero,
tone: SurfaceCardTone.chrome,
child: Column(
children: [
Expanded(
child: KeyedSubtree(
key: const Key('assistant-conversation-shell'),
child: ConversationAreaInternal(
controller: controller,
currentTask: currentTask,
items: timelineItems,
messageViewMode: controller.currentAssistantMessageViewMode,
bottomContentInset: composerBottomSpacing,
topTrailingInset: artifactPaneCollapsedInternal
? assistantCollapsedArtifactToggleClearanceInternal
: 0,
scrollController: conversationControllerInternal,
onOpenDetail: widget.onOpenDetail,
onFocusComposer:
AssistantPageStateActionsInternal(
this,
).focusComposerInternal,
onOpenGateway:
AssistantPageStateActionsInternal(
this,
).openGatewaySettingsInternal,
onOpenAiGatewaySettings:
AssistantPageStateActionsInternal(
this,
).openAiGatewaySettingsInternal,
onReconnectGateway:
AssistantPageStateActionsInternal(
this,
).connectFromSavedSettingsOrShowDialogInternal,
onMessageViewModeChanged:
controller.setAssistantMessageViewMode,
),
),
),
ColoredBox(
color: palette.canvas,
child: SizedBox(
key: const Key('assistant-workspace-resize-handle'),
height: assistantVerticalResizeHandleHeightInternal,
child: PaneResizeHandle(
axis: Axis.vertical,
onDelta: (delta) {
setState(() {
final nextComposerHeight = (composerHeight - delta)
.clamp(
composerHeightLowerBound,
composerHeightUpperBound,
)
.toDouble();
workspaceLowerPaneHeightAdjustmentInternal =
nextComposerHeight - defaultComposerHeight;
});
},
),
),
),
SizedBox(
key: const Key('assistant-composer-shell'),
height: composerHeight,
child: AssistantLowerPaneInternal(
bottomContentInset: composerBottomSpacing,
inputController: inputControllerInternal,
focusNode: composerFocusNodeInternal,
thinkingLabel: thinkingLabelInternal,
showModelControl: !controller.isSingleAgentMode
? true
: controller.currentSingleAgentShouldShowModelControl,
modelLabel: controller.isSingleAgentMode
? controller.currentSingleAgentModelDisplayLabel
: controller.resolvedAssistantModel.isEmpty
? appText('未选择模型', 'No model selected')
: controller.resolvedAssistantModel,
modelOptions: controller.assistantModelChoices,
attachments: attachmentsInternal,
availableSkills:
AssistantPageStateActionsInternal(
this,
).availableSkillOptionsInternal(controller),
selectedSkillKeys:
AssistantPageStateActionsInternal(
this,
).selectedSkillKeysForInternal(controller),
controller: controller,
onRemoveAttachment: (attachment) {
setState(() {
attachmentsInternal = attachmentsInternal
.where((item) => item.path != attachment.path)
.toList(growable: false);
});
},
onToggleSkill: (key) {
unawaited(
controller.toggleAssistantSkillForSession(
controller.currentSessionKey,
key,
),
);
AssistantPageStateActionsInternal(
this,
).focusComposerInternal();
},
onThinkingChanged: (value) {
setState(() => thinkingLabelInternal = value);
},
onModelChanged: (modelId) =>
controller.selectAssistantModelForSession(
controller.currentSessionKey,
modelId,
),
onOpenGateway:
AssistantPageStateActionsInternal(
this,
).openGatewaySettingsInternal,
onOpenAiGatewaySettings:
AssistantPageStateActionsInternal(
this,
).openAiGatewaySettingsInternal,
onReconnectGateway:
AssistantPageStateActionsInternal(
this,
).connectFromSavedSettingsOrShowDialogInternal,
onPickAttachments:
AssistantPageStateActionsInternal(
this,
).pickAttachmentsInternal,
onAddAttachment: (attachment) {
setState(() {
attachmentsInternal = [
...attachmentsInternal,
attachment,
];
});
},
onPasteImageAttachment:
widget.clipboardImageReader ??
readClipboardImageAsXFileInternal,
onComposerContentHeightChanged:
handleComposerContentHeightChangedInternal,
onComposerInputHeightChanged:
handleComposerInputHeightChangedInternal,
onSend:
AssistantPageStateActionsInternal(
this,
).submitPromptInternal,
),
),
],
),
);
},
);
}
Widget buildWorkspaceWithArtifactsInternal({
required AppController controller,
required AssistantTaskEntryInternal currentTask,
required Widget child,
}) {
return LayoutBuilder(
builder: (context, constraints) {
final maxPaneWidth = math.min(
560.0,
math.max(
assistantArtifactPaneMinWidthInternal,
constraints.maxWidth * 0.48,
),
);
final paneWidth = artifactPaneWidthInternal
.clamp(assistantArtifactPaneMinWidthInternal, maxPaneWidth)
.toDouble();
final panel = Row(
children: [
Expanded(child: child),
if (!artifactPaneCollapsedInternal) ...[
SizedBox(
key: const Key('assistant-artifact-pane-resize-handle'),
width: assistantHorizontalResizeHandleWidthInternal,
child: PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
artifactPaneWidthInternal =
(artifactPaneWidthInternal - delta)
.clamp(
assistantArtifactPaneMinWidthInternal,
maxPaneWidth,
)
.toDouble();
});
},
),
),
const SizedBox(width: assistantHorizontalPaneGapInternal),
SizedBox(
width: paneWidth,
child: AssistantArtifactSidebar(
sessionKey: controller.currentSessionKey,
threadTitle: currentTask.title,
workspaceRef: controller.assistantWorkspaceRefForSession(
controller.currentSessionKey,
),
workspaceRefKind: controller
.assistantWorkspaceRefKindForSession(
controller.currentSessionKey,
),
onCollapse: () {
setState(() {
artifactPaneCollapsedInternal = true;
});
},
loadSnapshot: () =>
controller.loadAssistantArtifactSnapshot(),
loadPreview: (entry) =>
controller.loadAssistantArtifactPreview(entry),
),
),
],
],
);
return Stack(
children: [
Positioned.fill(child: panel),
if (artifactPaneCollapsedInternal)
Positioned(
right: 0,
top: 0,
child: AssistantArtifactSidebarRevealButton(
onTap: () {
setState(() {
artifactPaneCollapsedInternal = false;
});
},
),
),
],
);
},
);
}
void handleComposerInputHeightChangedInternal(double value) {
if (!mounted || value == composerInputHeightInternal) {
return;
}
setState(() {
composerInputHeightInternal = value;
});
}
List<TimelineItemInternal> buildTimelineItemsInternal(
AppController controller,
List<GatewayChatMessage> messages,
) {
final items = <TimelineItemInternal>[];
final ownerLabel =
AssistantPageStateActionsInternal(
this,
).conversationOwnerLabelInternal(controller);
for (final message in messages) {
if ((message.toolName ?? '').trim().isNotEmpty) {
items.add(
TimelineItemInternal.toolCall(
toolName: message.toolName!,
summary: message.text,
pending: message.pending,
error: message.error,
),
);
continue;
}
final role = message.role.toLowerCase();
if (role == 'user') {
items.add(
TimelineItemInternal.message(
kind: TimelineItemKindInternal.user,
label: appText('', 'You'),
text: message.text,
pending: message.pending,
error: message.error,
),
);
} else if (role == 'assistant') {
items.add(
TimelineItemInternal.message(
kind: TimelineItemKindInternal.assistant,
label: kProductBrandName,
text: message.text,
pending: message.pending,
error: message.error,
),
);
} else {
items.add(
TimelineItemInternal.message(
kind: TimelineItemKindInternal.agent,
label: lastAutoAgentLabelInternal ?? ownerLabel,
text: message.text,
pending: message.pending,
error: message.error,
),
);
}
}
return items;
}
}

View File

@ -10,6 +10,7 @@ import 'secure_config_store.dart';
import 'runtime_controllers_gateway.dart';
import 'runtime_controllers_entities.dart';
import 'runtime_controllers_derived_tasks.dart';
import 'runtime_controllers_settings_connectivity_impl.dart';
class SettingsController extends ChangeNotifier {
SettingsController(this.storeInternal);
@ -279,266 +280,60 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<String> testOllamaConnection({required bool cloud}) async {
return testOllamaConnectionDraft(
cloud: cloud,
localConfig: snapshotInternal.ollamaLocal,
cloudConfig: snapshotInternal.ollamaCloud,
);
}
Future<String> testOllamaConnection({required bool cloud}) =>
testOllamaConnectionSettingsInternal(this, cloud: cloud);
Future<String> testOllamaConnectionDraft({
required bool cloud,
required OllamaLocalConfig localConfig,
required OllamaCloudConfig cloudConfig,
String apiKeyOverride = '',
}) async {
final base = cloud
? cloudConfig.baseUrl.trim()
: localConfig.endpoint.trim();
if (base.isEmpty) {
final message = 'Missing endpoint';
ollamaStatusInternal = message;
notifyListeners();
return message;
}
final cloudApiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await storeInternal.loadOllamaCloudApiKey())?.trim() ?? '';
try {
final uri = Uri.parse(
cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags',
);
final response = await simpleGetInternal(
uri,
headers: cloud
? <String, String>{
if (cloudApiKey.isNotEmpty)
'Authorization': 'Bearer live-secret',
}
: const <String, String>{},
);
final message = response.statusCode < 500
? 'Reachable (${response.statusCode})'
: 'Unhealthy (${response.statusCode})';
ollamaStatusInternal = message;
notifyListeners();
return message;
} catch (error) {
final message = 'Failed: $error';
ollamaStatusInternal = message;
notifyListeners();
return message;
}
}
}) => testOllamaConnectionDraftSettingsInternal(
this,
cloud: cloud,
localConfig: localConfig,
cloudConfig: cloudConfig,
apiKeyOverride: apiKeyOverride,
);
Future<String> testVaultConnection() async {
return testVaultConnectionDraft(snapshotInternal.vault);
}
Future<String> testVaultConnection() =>
testVaultConnectionSettingsInternal(this);
Future<String> testVaultConnectionDraft(
VaultConfig profile, {
String tokenOverride = '',
}) async {
final address = profile.address.trim();
if (address.isEmpty) {
const message = 'Missing address';
vaultStatusInternal = message;
notifyListeners();
return message;
}
try {
final uri = Uri.parse(
'$address${address.endsWith('/') ? '' : '/'}v1/sys/health',
);
final headers = <String, String>{
if (profile.namespace.trim().isNotEmpty)
'X-Vault-Namespace': profile.namespace.trim(),
};
final token = tokenOverride.trim().isNotEmpty
? tokenOverride.trim()
: (await storeInternal.loadVaultToken())?.trim() ?? '';
if (token.trim().isNotEmpty) {
headers['X-Vault-Token'] = token.trim();
}
final response = await simpleGetInternal(uri, headers: headers);
final message = response.statusCode < 500
? 'Reachable (${response.statusCode})'
: 'Unhealthy (${response.statusCode})';
vaultStatusInternal = message;
notifyListeners();
return message;
} catch (error) {
final message = 'Failed: $error';
vaultStatusInternal = message;
notifyListeners();
return message;
}
}
}) => testVaultConnectionDraftSettingsInternal(
this,
profile,
tokenOverride: tokenOverride,
);
Future<AiGatewayProfile> syncAiGatewayCatalog(
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal(
profile.baseUrl,
);
if (normalizedBaseUrl == null) {
final next = profile.copyWith(
syncState: 'invalid',
syncMessage: 'Missing LLM API Endpoint',
);
aiGatewayStatusInternal = next.syncMessage;
snapshotInternal = snapshotInternal.copyWith(aiGateway: next);
await storeInternal.saveSettingsSnapshot(snapshotInternal);
notifyListeners();
return next;
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'invalid',
syncMessage: 'Missing LLM API Token',
);
aiGatewayStatusInternal = next.syncMessage;
snapshotInternal = snapshotInternal.copyWith(aiGateway: next);
await storeInternal.saveSettingsSnapshot(snapshotInternal);
notifyListeners();
return next;
}
try {
final models = await loadAiGatewayModels(
profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()),
apiKeyOverride: apiKey,
);
final availableModels = models
.map((item) => item.id)
.toList(growable: false);
final retainedSelected = profile.selectedModels
.where(availableModels.contains)
.toList(growable: false);
final selectedModels = retainedSelected.isNotEmpty
? retainedSelected
: availableModels.take(5).toList(growable: false);
final currentDefaultModel = snapshotInternal.defaultModel.trim();
final resolvedDefaultModel = selectedModels.contains(currentDefaultModel)
? currentDefaultModel
: selectedModels.isNotEmpty
? selectedModels.first
: availableModels.isNotEmpty
? availableModels.first
: '';
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
availableModels: availableModels,
selectedModels: selectedModels,
syncState: 'ready',
syncMessage: 'Loaded ${availableModels.length} model(s)',
);
aiGatewayStatusInternal = 'Ready (${availableModels.length})';
snapshotInternal = snapshotInternal.copyWith(
aiGateway: next,
defaultModel: resolvedDefaultModel,
);
await storeInternal.saveSettingsSnapshot(snapshotInternal);
await reloadDerivedStateInternal();
notifyListeners();
return next;
} catch (error) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'error',
syncMessage: networkErrorLabelInternal(error),
);
aiGatewayStatusInternal = next.syncMessage;
snapshotInternal = snapshotInternal.copyWith(aiGateway: next);
await storeInternal.saveSettingsSnapshot(snapshotInternal);
notifyListeners();
return next;
}
}
}) => syncAiGatewayCatalogSettingsInternal(
this,
profile,
apiKeyOverride: apiKeyOverride,
);
Future<AiGatewayConnectionCheck> testAiGatewayConnection(
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal(
profile.baseUrl,
);
if (normalizedBaseUrl == null) {
return const AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Endpoint',
endpoint: '',
modelCount: 0,
);
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
final endpoint = aiGatewayModelsUriInternal(normalizedBaseUrl).toString();
if (apiKey.isEmpty) {
return AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Token',
endpoint: endpoint,
modelCount: 0,
);
}
try {
final models = await requestAiGatewayModelsInternal(
uri: aiGatewayModelsUriInternal(normalizedBaseUrl),
apiKey: apiKey,
);
if (models.isEmpty) {
return AiGatewayConnectionCheck(
state: 'empty',
message: 'Authenticated but no models were returned',
endpoint: endpoint,
modelCount: 0,
);
}
return AiGatewayConnectionCheck(
state: 'ready',
message: 'Authenticated · ${models.length} model(s) available',
endpoint: endpoint,
modelCount: models.length,
);
} catch (error) {
return AiGatewayConnectionCheck(
state: 'error',
message: networkErrorLabelInternal(error),
endpoint: endpoint,
modelCount: 0,
);
}
}
}) => testAiGatewayConnectionSettingsInternal(
this,
profile,
apiKeyOverride: apiKeyOverride,
);
Future<List<GatewayModelSummary>> loadAiGatewayModels({
AiGatewayProfile? profile,
String apiKeyOverride = '',
}) async {
final activeProfile = profile ?? snapshotInternal.aiGateway;
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal(
activeProfile.baseUrl,
);
if (normalizedBaseUrl == null) {
return const <GatewayModelSummary>[];
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
return const <GatewayModelSummary>[];
}
return requestAiGatewayModelsInternal(
uri: aiGatewayModelsUriInternal(normalizedBaseUrl),
apiKey: apiKey,
);
}
}) => loadAiGatewayModelsSettingsInternal(
this,
profile: profile,
apiKeyOverride: apiKeyOverride,
);
List<SecretReferenceEntry> buildSecretReferences() {
final entries = <SecretReferenceEntry>[

View File

@ -0,0 +1,295 @@
// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'gateway_runtime.dart';
import 'runtime_models.dart';
import 'secure_config_store.dart';
import 'runtime_controllers_gateway.dart';
import 'runtime_controllers_entities.dart';
import 'runtime_controllers_derived_tasks.dart';
import 'runtime_controllers_settings.dart';
Future<String> testOllamaConnectionSettingsInternal(
SettingsController controller, {
required bool cloud,
}) {
return testOllamaConnectionDraftSettingsInternal(
controller,
cloud: cloud,
localConfig: controller.snapshotInternal.ollamaLocal,
cloudConfig: controller.snapshotInternal.ollamaCloud,
);
}
Future<String> testOllamaConnectionDraftSettingsInternal(
SettingsController controller, {
required bool cloud,
required OllamaLocalConfig localConfig,
required OllamaCloudConfig cloudConfig,
String apiKeyOverride = '',
}) async {
final base = cloud ? cloudConfig.baseUrl.trim() : localConfig.endpoint.trim();
if (base.isEmpty) {
final message = 'Missing endpoint';
controller.ollamaStatusInternal = message;
controller.notifyListeners();
return message;
}
final cloudApiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await controller.storeInternal.loadOllamaCloudApiKey())?.trim() ?? '';
try {
final uri = Uri.parse(
cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags',
);
final response = await controller.simpleGetInternal(
uri,
headers: cloud
? <String, String>{
if (cloudApiKey.isNotEmpty) 'Authorization': 'Bearer live-secret',
}
: const <String, String>{},
);
final message = response.statusCode < 500
? 'Reachable (${response.statusCode})'
: 'Unhealthy (${response.statusCode})';
controller.ollamaStatusInternal = message;
controller.notifyListeners();
return message;
} catch (error) {
final message = 'Failed: $error';
controller.ollamaStatusInternal = message;
controller.notifyListeners();
return message;
}
}
Future<String> testVaultConnectionSettingsInternal(
SettingsController controller,
) {
return testVaultConnectionDraftSettingsInternal(
controller,
controller.snapshotInternal.vault,
);
}
Future<String> testVaultConnectionDraftSettingsInternal(
SettingsController controller,
VaultConfig profile, {
String tokenOverride = '',
}) async {
final address = profile.address.trim();
if (address.isEmpty) {
const message = 'Missing address';
controller.vaultStatusInternal = message;
controller.notifyListeners();
return message;
}
try {
final uri = Uri.parse(
'$address${address.endsWith('/') ? '' : '/'}v1/sys/health',
);
final headers = <String, String>{
if (profile.namespace.trim().isNotEmpty)
'X-Vault-Namespace': profile.namespace.trim(),
};
final token = tokenOverride.trim().isNotEmpty
? tokenOverride.trim()
: (await controller.storeInternal.loadVaultToken())?.trim() ?? '';
if (token.trim().isNotEmpty) {
headers['X-Vault-Token'] = token.trim();
}
final response = await controller.simpleGetInternal(uri, headers: headers);
final message = response.statusCode < 500
? 'Reachable (${response.statusCode})'
: 'Unhealthy (${response.statusCode})';
controller.vaultStatusInternal = message;
controller.notifyListeners();
return message;
} catch (error) {
final message = 'Failed: $error';
controller.vaultStatusInternal = message;
controller.notifyListeners();
return message;
}
}
Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
SettingsController controller,
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
profile.baseUrl,
);
if (normalizedBaseUrl == null) {
final next = profile.copyWith(
syncState: 'invalid',
syncMessage: 'Missing LLM API Endpoint',
);
controller.aiGatewayStatusInternal = next.syncMessage;
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
);
await controller.storeInternal.saveSettingsSnapshot(
controller.snapshotInternal,
);
controller.notifyListeners();
return next;
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'invalid',
syncMessage: 'Missing LLM API Token',
);
controller.aiGatewayStatusInternal = next.syncMessage;
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
);
await controller.storeInternal.saveSettingsSnapshot(
controller.snapshotInternal,
);
controller.notifyListeners();
return next;
}
try {
final models = await loadAiGatewayModelsSettingsInternal(
controller,
profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()),
apiKeyOverride: apiKey,
);
final availableModels = models.map((item) => item.id).toList(growable: false);
final retainedSelected = profile.selectedModels
.where(availableModels.contains)
.toList(growable: false);
final selectedModels = retainedSelected.isNotEmpty
? retainedSelected
: availableModels.take(5).toList(growable: false);
final currentDefaultModel = controller.snapshotInternal.defaultModel.trim();
final resolvedDefaultModel = selectedModels.contains(currentDefaultModel)
? currentDefaultModel
: selectedModels.isNotEmpty
? selectedModels.first
: availableModels.isNotEmpty
? availableModels.first
: '';
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
availableModels: availableModels,
selectedModels: selectedModels,
syncState: 'ready',
syncMessage: 'Loaded ${availableModels.length} model(s)',
);
controller.aiGatewayStatusInternal = 'Ready (${availableModels.length})';
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
defaultModel: resolvedDefaultModel,
);
await controller.storeInternal.saveSettingsSnapshot(controller.snapshotInternal);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
return next;
} catch (error) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'error',
syncMessage: controller.networkErrorLabelInternal(error),
);
controller.aiGatewayStatusInternal = next.syncMessage;
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
);
await controller.storeInternal.saveSettingsSnapshot(controller.snapshotInternal);
controller.notifyListeners();
return next;
}
}
Future<AiGatewayConnectionCheck> testAiGatewayConnectionSettingsInternal(
SettingsController controller,
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
profile.baseUrl,
);
if (normalizedBaseUrl == null) {
return const AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Endpoint',
endpoint: '',
modelCount: 0,
);
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
final endpoint = controller.aiGatewayModelsUriInternal(normalizedBaseUrl)
.toString();
if (apiKey.isEmpty) {
return AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Token',
endpoint: endpoint,
modelCount: 0,
);
}
try {
final models = await controller.requestAiGatewayModelsInternal(
uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl),
apiKey: apiKey,
);
if (models.isEmpty) {
return AiGatewayConnectionCheck(
state: 'empty',
message: 'Authenticated but no models were returned',
endpoint: endpoint,
modelCount: 0,
);
}
return AiGatewayConnectionCheck(
state: 'ready',
message: 'Authenticated · ${models.length} model(s) available',
endpoint: endpoint,
modelCount: models.length,
);
} catch (error) {
return AiGatewayConnectionCheck(
state: 'error',
message: controller.networkErrorLabelInternal(error),
endpoint: endpoint,
modelCount: 0,
);
}
}
Future<List<GatewayModelSummary>> loadAiGatewayModelsSettingsInternal(
SettingsController controller, {
AiGatewayProfile? profile,
String apiKeyOverride = '',
}) async {
final activeProfile = profile ?? controller.snapshotInternal.aiGateway;
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
activeProfile.baseUrl,
);
if (normalizedBaseUrl == null) {
return const <GatewayModelSummary>[];
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
return const <GatewayModelSummary>[];
}
return controller.requestAiGatewayModelsInternal(
uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl),
apiKey: apiKey,
);
}

View File

@ -17,16 +17,20 @@ void main() {
// Baseline cap for legacy oversized closure; tighten after T3.
'lib/runtime/gateway_runtime_core.dart': 950,
'lib/runtime/gateway_runtime_helpers.dart': 800,
// Baseline cap for legacy oversized closure; tighten after T3.
'lib/runtime/runtime_controllers_settings.dart': 960,
// Tightened after T3 closure convergence.
'lib/runtime/runtime_controllers_settings.dart': 800,
'lib/features/settings/settings_page_gateway.dart': 800,
'test/runtime/app_controller_assistant_flow_suite.dart': 800,
'test/runtime/app_controller_thread_skills_suite.dart': 800,
// Tightened in T2 after assistant/composer closure split.
'lib/features/assistant/assistant_page_main.dart': 2200,
'lib/app/app_controller_desktop_runtime_helpers.dart': 950,
'lib/app/app_controller_desktop_thread_sessions.dart': 1050,
// Tightened in T2/T3 after assistant + app/runtime closure split.
'lib/features/assistant/assistant_page_main.dart': 1000,
'lib/app/app_controller_desktop_runtime_helpers.dart': 800,
'lib/app/app_controller_desktop_thread_sessions.dart': 800,
'lib/app/app_controller_desktop_runtime_coordination_impl.dart': 800,
'lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart':
800,
'lib/runtime/runtime_controllers_settings_connectivity_impl.dart': 800,
};
final violations = <String>[];
for (final entry in targets.entries) {