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. 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. 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 ### Workflow Composition
The standard combines: 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_settings_runtime.dart';
import 'app_controller_desktop_thread_storage.dart'; import 'app_controller_desktop_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.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 // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
extension AppControllerDesktopRuntimeHelpers on AppController { extension AppControllerDesktopRuntimeHelpers on AppController {
@ -379,64 +380,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
Future<void> refreshAcpCapabilitiesInternal({ Future<void> refreshAcpCapabilitiesInternal({
bool forceRefresh = false, bool forceRefresh = false,
bool persistMountTargets = false, bool persistMountTargets = false,
}) async { }) => refreshAcpCapabilitiesRuntimeInternal(
GatewayAcpCapabilities capabilities; this,
try { forceRefresh: forceRefresh,
capabilities = await gatewayAcpClientInternal.loadCapabilities( persistMountTargets: persistMountTargets,
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();
}
Future<void> refreshSingleAgentCapabilitiesInternal({ Future<void> refreshSingleAgentCapabilitiesInternal({
bool forceRefresh = false, bool forceRefresh = false,
}) async { }) => refreshSingleAgentCapabilitiesRuntimeInternal(
final gatewayToken = await settingsController.loadGatewayToken(); this,
final next = <SingleAgentProvider, DirectSingleAgentCapabilities>{}; forceRefresh: forceRefresh,
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();
}
}
Future<void> refreshResolvedCodexCliPathInternal() async { Future<void> refreshResolvedCodexCliPathInternal() async {
if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) { if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) {
@ -471,116 +426,36 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
List<ManagedMountTargetState> mergeAcpCapabilitiesIntoMountTargetsInternal( List<ManagedMountTargetState> mergeAcpCapabilitiesIntoMountTargetsInternal(
List<ManagedMountTargetState> current, List<ManagedMountTargetState> current,
GatewayAcpCapabilities capabilities, GatewayAcpCapabilities capabilities,
) { ) => mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal(
final source = current.isEmpty this,
? ManagedMountTargetState.defaults() current,
: current; capabilities,
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? assistantWorkingDirectoryForSessionInternal(String sessionKey) { String? assistantWorkingDirectoryForSessionInternal(String sessionKey) =>
final candidate = assistantWorkspaceRefForSession(sessionKey).trim(); assistantWorkingDirectoryForSessionRuntimeInternal(this, sessionKey);
if (candidate.isEmpty) {
return null;
}
return candidate;
}
String? resolveLocalAssistantWorkingDirectoryForSessionInternal( String? resolveLocalAssistantWorkingDirectoryForSessionInternal(
String sessionKey, { String sessionKey, {
bool requireLocalExistence = true, bool requireLocalExistence = true,
}) { }) => resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
if (assistantWorkspaceRefKindForSession(sessionKey) != this,
WorkspaceRefKind.localPath) { sessionKey,
return null; requireLocalExistence: requireLocalExistence,
} );
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;
}
String? resolveSingleAgentWorkingDirectoryForSessionInternal( String? resolveSingleAgentWorkingDirectoryForSessionInternal(
String sessionKey, { String sessionKey, {
SingleAgentProvider? provider, SingleAgentProvider? provider,
}) { }) => resolveSingleAgentWorkingDirectoryForSessionRuntimeInternal(
final workspaceKind = assistantWorkspaceRefKindForSession(sessionKey); this,
if (workspaceKind == WorkspaceRefKind.objectStore) { sessionKey,
return null; provider: provider,
} );
if (workspaceKind == WorkspaceRefKind.remotePath) {
return assistantWorkingDirectoryForSessionInternal(sessionKey);
}
return resolveLocalAssistantWorkingDirectoryForSessionInternal(
sessionKey,
requireLocalExistence:
provider == null ||
singleAgentProviderRequiresLocalPathInternal(provider),
);
}
bool singleAgentProviderRequiresLocalPathInternal( bool singleAgentProviderRequiresLocalPathInternal(
SingleAgentProvider provider, SingleAgentProvider provider,
) { ) => singleAgentProviderRequiresLocalPathRuntimeInternal(this, 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;
}
void registerCodexExternalProviderInternal() { void registerCodexExternalProviderInternal() {
runtimeCoordinatorInternal.registerExternalCodeAgent( runtimeCoordinatorInternal.registerExternalCodeAgent(
@ -603,117 +478,19 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
); );
} }
CodeAgentNodeState buildCodeAgentNodeStateInternal() { CodeAgentNodeState buildCodeAgentNodeStateInternal() =>
return CodeAgentNodeState( buildCodeAgentNodeStateRuntimeInternal(this);
selectedAgentId: agentsControllerInternal.selectedAgentId,
gatewayConnected: runtimeInternal.isConnected,
executionTarget: currentAssistantExecutionTarget,
runtimeMode: effectiveCodeAgentRuntimeMode,
bridgeEnabled: isCodexBridgeEnabledInternal,
bridgeState: codexCooperationStateInternal.name,
preferredProviderId: 'codex',
resolvedCodexCliPath: resolvedCodexCliPathInternal,
configuredCodexCliPath: configuredCodexCliPath,
);
}
GatewayMode bridgeGatewayModeInternal() { GatewayMode bridgeGatewayModeInternal() =>
if (!runtimeInternal.isConnected) { bridgeGatewayModeRuntimeInternal(this);
return GatewayMode.offline;
}
return switch (currentAssistantExecutionTarget) {
AssistantExecutionTarget.singleAgent => GatewayMode.offline,
AssistantExecutionTarget.local => GatewayMode.local,
AssistantExecutionTarget.remote => GatewayMode.remote,
};
}
Future<void> ensureCodexGatewayRegistrationInternal() async { Future<void> ensureCodexGatewayRegistrationInternal() =>
if (!isCodexBridgeEnabledInternal) { ensureCodexGatewayRegistrationRuntimeInternal(this);
return;
}
if (!runtimeInternal.isConnected) { void clearCodexGatewayRegistrationInternal() =>
codexCooperationStateInternal = CodexCooperationState.bridgeOnly; clearCodexGatewayRegistrationRuntimeInternal(this);
codeAgentBridgeRegistryInternal.clearRegistration();
notifyListeners();
return;
}
if (codeAgentBridgeRegistryInternal.isRegistered) { void recomputeTasksInternal() => recomputeTasksRuntimeInternal(this);
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 attachChildListenersInternal() { void attachChildListenersInternal() {
runtimeCoordinatorInternal.addListener(relayChildChangeInternal); 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_thread_storage.dart';
import 'app_controller_desktop_skill_permissions.dart'; import 'app_controller_desktop_skill_permissions.dart';
import 'app_controller_desktop_runtime_helpers.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 // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
extension AppControllerDesktopThreadSessions on AppController { extension AppControllerDesktopThreadSessions on AppController {
@ -430,293 +431,45 @@ extension AppControllerDesktopThreadSessions on AppController {
String get assistantConnectionStatusLabel => String get assistantConnectionStatusLabel =>
currentAssistantConnectionState.primaryLabel; currentAssistantConnectionState.primaryLabel;
String get assistantConnectionTargetLabel =>
currentAssistantConnectionState.detailLabel;
String get assistantConnectionTargetLabel { Future<String> loadAiGatewayApiKey() =>
return currentAssistantConnectionState.detailLabel; loadAiGatewayApiKeyThreadSessionInternal(this);
} Future<void> saveMultiAgentConfig(MultiAgentConfig config) =>
saveMultiAgentConfigThreadSessionInternal(this, config);
Future<String> loadAiGatewayApiKey() async { Future<void> refreshMultiAgentMounts({bool sync = false}) =>
return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; refreshMultiAgentMountsThreadSessionInternal(this, sync: sync);
}
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<void> runMultiAgentCollaboration({ Future<void> runMultiAgentCollaboration({
required String rawPrompt, required String rawPrompt,
required String composedPrompt, required String composedPrompt,
required List<CollaborationAttachment> attachments, required List<CollaborationAttachment> attachments,
required List<String> selectedSkillLabels, required List<String> selectedSkillLabels,
}) async { }) => runMultiAgentCollaborationThreadSessionInternal(
final sessionKey = currentSessionKey.trim().isEmpty this,
? 'main' rawPrompt: rawPrompt,
: currentSessionKey; composedPrompt: composedPrompt,
await enqueueThreadTurnInternal<void>(sessionKey, () async { attachments: attachments,
final aiGatewayApiKey = await loadAiGatewayApiKey(); selectedSkillLabels: selectedSkillLabels,
multiAgentRunPendingInternal = true; );
appendLocalSessionMessageInternal( Future<void> openOnlineWorkspace() =>
sessionKey, openOnlineWorkspaceThreadSessionInternal(this);
GatewayChatMessage( List<String> get aiGatewayModelChoices =>
id: nextLocalMessageIdInternal(), aiGatewayModelChoicesThreadSessionInternal(this);
role: 'user', List<String> get connectedGatewayModelChoices =>
text: rawPrompt, connectedGatewayModelChoicesThreadSessionInternal(this);
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), List<String> get assistantModelChoices =>
toolCallId: null, assistantModelChoicesThreadSessionInternal(this);
toolName: null, List<String> assistantModelChoicesForSessionInternal(String sessionKey) =>
stopReason: null, assistantModelChoicesForSessionThreadSessionInternal(this, sessionKey);
pending: false, String get resolvedDefaultModel =>
error: false, resolvedDefaultModelThreadSessionInternal(this);
), bool get canQuickConnectGateway =>
); canQuickConnectGatewayThreadSessionInternal(this);
recomputeTasksInternal(); String joinConnectionPartsInternal(List<String> parts) =>
try { joinConnectionPartsThreadSessionInternal(parts);
final taskStream = gatewayAcpClientInternal.runMultiAgent( String gatewayAddressLabelInternal(GatewayConnectionProfile profile) =>
GatewayAcpMultiAgentRequest( gatewayAddressLabelThreadSessionInternal(profile);
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}';
}
List<SecretReferenceEntry> get secretReferences => List<SecretReferenceEntry> get secretReferences =>
settingsControllerInternal.buildSecretReferences(); 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_gateway.dart';
import 'runtime_controllers_entities.dart'; import 'runtime_controllers_entities.dart';
import 'runtime_controllers_derived_tasks.dart'; import 'runtime_controllers_derived_tasks.dart';
import 'runtime_controllers_settings_connectivity_impl.dart';
class SettingsController extends ChangeNotifier { class SettingsController extends ChangeNotifier {
SettingsController(this.storeInternal); SettingsController(this.storeInternal);
@ -279,266 +280,60 @@ class SettingsController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<String> testOllamaConnection({required bool cloud}) async { Future<String> testOllamaConnection({required bool cloud}) =>
return testOllamaConnectionDraft( testOllamaConnectionSettingsInternal(this, cloud: cloud);
cloud: cloud,
localConfig: snapshotInternal.ollamaLocal,
cloudConfig: snapshotInternal.ollamaCloud,
);
}
Future<String> testOllamaConnectionDraft({ Future<String> testOllamaConnectionDraft({
required bool cloud, required bool cloud,
required OllamaLocalConfig localConfig, required OllamaLocalConfig localConfig,
required OllamaCloudConfig cloudConfig, required OllamaCloudConfig cloudConfig,
String apiKeyOverride = '', String apiKeyOverride = '',
}) async { }) => testOllamaConnectionDraftSettingsInternal(
final base = cloud this,
? cloudConfig.baseUrl.trim() cloud: cloud,
: localConfig.endpoint.trim(); localConfig: localConfig,
if (base.isEmpty) { cloudConfig: cloudConfig,
final message = 'Missing endpoint'; apiKeyOverride: apiKeyOverride,
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;
}
}
Future<String> testVaultConnection() async { Future<String> testVaultConnection() =>
return testVaultConnectionDraft(snapshotInternal.vault); testVaultConnectionSettingsInternal(this);
}
Future<String> testVaultConnectionDraft( Future<String> testVaultConnectionDraft(
VaultConfig profile, { VaultConfig profile, {
String tokenOverride = '', String tokenOverride = '',
}) async { }) => testVaultConnectionDraftSettingsInternal(
final address = profile.address.trim(); this,
if (address.isEmpty) { profile,
const message = 'Missing address'; tokenOverride: tokenOverride,
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;
}
}
Future<AiGatewayProfile> syncAiGatewayCatalog( Future<AiGatewayProfile> syncAiGatewayCatalog(
AiGatewayProfile profile, { AiGatewayProfile profile, {
String apiKeyOverride = '', String apiKeyOverride = '',
}) async { }) => syncAiGatewayCatalogSettingsInternal(
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal( this,
profile.baseUrl, profile,
); apiKeyOverride: apiKeyOverride,
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;
}
}
Future<AiGatewayConnectionCheck> testAiGatewayConnection( Future<AiGatewayConnectionCheck> testAiGatewayConnection(
AiGatewayProfile profile, { AiGatewayProfile profile, {
String apiKeyOverride = '', String apiKeyOverride = '',
}) async { }) => testAiGatewayConnectionSettingsInternal(
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal( this,
profile.baseUrl, profile,
); apiKeyOverride: apiKeyOverride,
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,
);
}
}
Future<List<GatewayModelSummary>> loadAiGatewayModels({ Future<List<GatewayModelSummary>> loadAiGatewayModels({
AiGatewayProfile? profile, AiGatewayProfile? profile,
String apiKeyOverride = '', String apiKeyOverride = '',
}) async { }) => loadAiGatewayModelsSettingsInternal(
final activeProfile = profile ?? snapshotInternal.aiGateway; this,
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal( profile: profile,
activeProfile.baseUrl, apiKeyOverride: apiKeyOverride,
); );
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,
);
}
List<SecretReferenceEntry> buildSecretReferences() { List<SecretReferenceEntry> buildSecretReferences() {
final entries = <SecretReferenceEntry>[ 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. // Baseline cap for legacy oversized closure; tighten after T3.
'lib/runtime/gateway_runtime_core.dart': 950, 'lib/runtime/gateway_runtime_core.dart': 950,
'lib/runtime/gateway_runtime_helpers.dart': 800, 'lib/runtime/gateway_runtime_helpers.dart': 800,
// Baseline cap for legacy oversized closure; tighten after T3. // Tightened after T3 closure convergence.
'lib/runtime/runtime_controllers_settings.dart': 960, 'lib/runtime/runtime_controllers_settings.dart': 800,
'lib/features/settings/settings_page_gateway.dart': 800, 'lib/features/settings/settings_page_gateway.dart': 800,
'test/runtime/app_controller_assistant_flow_suite.dart': 800, 'test/runtime/app_controller_assistant_flow_suite.dart': 800,
'test/runtime/app_controller_thread_skills_suite.dart': 800, 'test/runtime/app_controller_thread_skills_suite.dart': 800,
// Tightened in T2 after assistant/composer closure split. // Tightened in T2/T3 after assistant + app/runtime closure split.
'lib/features/assistant/assistant_page_main.dart': 2200, 'lib/features/assistant/assistant_page_main.dart': 1000,
'lib/app/app_controller_desktop_runtime_helpers.dart': 950, 'lib/app/app_controller_desktop_runtime_helpers.dart': 800,
'lib/app/app_controller_desktop_thread_sessions.dart': 1050, '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>[]; final violations = <String>[];
for (final entry in targets.entries) { for (final entry in targets.entries) {