refactor: execute stage0-5 workflow and tighten closure guards
This commit is contained in:
parent
f67d8300de
commit
e3760b3638
@ -16,6 +16,10 @@
|
||||
This section defines the reusable refactor workflow for this repo.
|
||||
When trigger conditions are met, the workflow is executed by default without additional confirmation prompts.
|
||||
|
||||
Normative source:
|
||||
- Use this section as the single enforcement source for refactor execution rules.
|
||||
- ADR documents only record decision background and must not introduce conflicting rules.
|
||||
|
||||
### Workflow Composition
|
||||
|
||||
The standard combines:
|
||||
|
||||
38
docs/architecture/refactor-style-no-part-adr.md
Normal file
38
docs/architecture/refactor-style-no-part-adr.md
Normal 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`.
|
||||
28
docs/architecture/stage4-helper-ownership-20260328.md
Normal file
28
docs/architecture/stage4-helper-ownership-20260328.md
Normal 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.
|
||||
384
lib/app/app_controller_desktop_runtime_coordination_impl.dart
Normal file
384
lib/app/app_controller_desktop_runtime_coordination_impl.dart
Normal 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;
|
||||
}
|
||||
@ -45,6 +45,7 @@ import 'app_controller_desktop_workspace_execution.dart';
|
||||
import 'app_controller_desktop_settings_runtime.dart';
|
||||
import 'app_controller_desktop_thread_storage.dart';
|
||||
import 'app_controller_desktop_skill_permissions.dart';
|
||||
import 'app_controller_desktop_runtime_coordination_impl.dart';
|
||||
|
||||
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
|
||||
extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
@ -379,64 +380,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
Future<void> refreshAcpCapabilitiesInternal({
|
||||
bool forceRefresh = false,
|
||||
bool persistMountTargets = false,
|
||||
}) async {
|
||||
GatewayAcpCapabilities capabilities;
|
||||
try {
|
||||
capabilities = await gatewayAcpClientInternal.loadCapabilities(
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
} catch (_) {
|
||||
capabilities = const GatewayAcpCapabilities.empty();
|
||||
}
|
||||
if (persistMountTargets && !disposedInternal) {
|
||||
final currentConfig = settings.multiAgent;
|
||||
final nextTargets = mergeAcpCapabilitiesIntoMountTargetsInternal(
|
||||
currentConfig.mountTargets,
|
||||
capabilities,
|
||||
);
|
||||
final nextConfig = currentConfig.copyWith(mountTargets: nextTargets);
|
||||
if (jsonEncode(nextConfig.toJson()) !=
|
||||
jsonEncode(currentConfig.toJson())) {
|
||||
await settingsControllerInternal.saveSnapshot(
|
||||
settings.copyWith(multiAgent: nextConfig),
|
||||
);
|
||||
multiAgentOrchestratorInternal.updateConfig(nextConfig);
|
||||
}
|
||||
}
|
||||
notifyIfActiveInternal();
|
||||
}
|
||||
}) => refreshAcpCapabilitiesRuntimeInternal(
|
||||
this,
|
||||
forceRefresh: forceRefresh,
|
||||
persistMountTargets: persistMountTargets,
|
||||
);
|
||||
|
||||
Future<void> refreshSingleAgentCapabilitiesInternal({
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final gatewayToken = await settingsController.loadGatewayToken();
|
||||
final next = <SingleAgentProvider, DirectSingleAgentCapabilities>{};
|
||||
for (final provider in configuredSingleAgentProviders) {
|
||||
final profile = settings.externalAcpEndpointForProvider(provider);
|
||||
if (!profile.enabled || profile.endpoint.trim().isEmpty) {
|
||||
next[provider] = const DirectSingleAgentCapabilities.unavailable(
|
||||
endpoint: '',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
next[provider] = await singleAgentAppServerClientInternal
|
||||
.loadCapabilities(
|
||||
provider: provider,
|
||||
forceRefresh: forceRefresh,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
} catch (_) {
|
||||
next[provider] = const DirectSingleAgentCapabilities.unavailable(
|
||||
endpoint: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
singleAgentCapabilitiesByProviderInternal = next;
|
||||
if (!disposedInternal) {
|
||||
notifyIfActiveInternal();
|
||||
}
|
||||
}
|
||||
}) => refreshSingleAgentCapabilitiesRuntimeInternal(
|
||||
this,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
|
||||
Future<void> refreshResolvedCodexCliPathInternal() async {
|
||||
if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) {
|
||||
@ -471,116 +426,36 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
List<ManagedMountTargetState> mergeAcpCapabilitiesIntoMountTargetsInternal(
|
||||
List<ManagedMountTargetState> current,
|
||||
GatewayAcpCapabilities capabilities,
|
||||
) {
|
||||
final source = current.isEmpty
|
||||
? ManagedMountTargetState.defaults()
|
||||
: current;
|
||||
final providers = capabilities.providers
|
||||
.map((item) => item.providerId)
|
||||
.toSet();
|
||||
return source
|
||||
.map((item) {
|
||||
final available = switch (item.targetId) {
|
||||
'codex' => providers.contains('codex'),
|
||||
'opencode' => providers.contains('opencode'),
|
||||
'claude' => providers.contains('claude'),
|
||||
'gemini' => providers.contains('gemini'),
|
||||
'aris' => capabilities.multiAgent,
|
||||
'openclaw' => capabilities.multiAgent || capabilities.singleAgent,
|
||||
_ => false,
|
||||
};
|
||||
return item.copyWith(
|
||||
available: available,
|
||||
discoveryState: available ? 'ready' : 'unavailable',
|
||||
syncState: available ? item.syncState : 'idle',
|
||||
detail: available
|
||||
? appText(
|
||||
'来源:Gateway ACP capabilities',
|
||||
'Source: Gateway ACP capabilities',
|
||||
)
|
||||
: appText(
|
||||
'Gateway ACP 未报告该能力。',
|
||||
'Gateway ACP did not report this capability.',
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
) => mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal(
|
||||
this,
|
||||
current,
|
||||
capabilities,
|
||||
);
|
||||
|
||||
String? assistantWorkingDirectoryForSessionInternal(String sessionKey) {
|
||||
final candidate = assistantWorkspaceRefForSession(sessionKey).trim();
|
||||
if (candidate.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
String? assistantWorkingDirectoryForSessionInternal(String sessionKey) =>
|
||||
assistantWorkingDirectoryForSessionRuntimeInternal(this, sessionKey);
|
||||
|
||||
String? resolveLocalAssistantWorkingDirectoryForSessionInternal(
|
||||
String sessionKey, {
|
||||
bool requireLocalExistence = true,
|
||||
}) {
|
||||
if (assistantWorkspaceRefKindForSession(sessionKey) !=
|
||||
WorkspaceRefKind.localPath) {
|
||||
return null;
|
||||
}
|
||||
final candidate = assistantWorkingDirectoryForSessionInternal(sessionKey);
|
||||
if (candidate == null) {
|
||||
return null;
|
||||
}
|
||||
final directory = Directory(candidate);
|
||||
if (directory.existsSync()) {
|
||||
return directory.path;
|
||||
}
|
||||
if (requireLocalExistence) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}) => resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal(
|
||||
this,
|
||||
sessionKey,
|
||||
requireLocalExistence: requireLocalExistence,
|
||||
);
|
||||
|
||||
String? resolveSingleAgentWorkingDirectoryForSessionInternal(
|
||||
String sessionKey, {
|
||||
SingleAgentProvider? provider,
|
||||
}) {
|
||||
final workspaceKind = assistantWorkspaceRefKindForSession(sessionKey);
|
||||
if (workspaceKind == WorkspaceRefKind.objectStore) {
|
||||
return null;
|
||||
}
|
||||
if (workspaceKind == WorkspaceRefKind.remotePath) {
|
||||
return assistantWorkingDirectoryForSessionInternal(sessionKey);
|
||||
}
|
||||
return resolveLocalAssistantWorkingDirectoryForSessionInternal(
|
||||
sessionKey,
|
||||
requireLocalExistence:
|
||||
provider == null ||
|
||||
singleAgentProviderRequiresLocalPathInternal(provider),
|
||||
);
|
||||
}
|
||||
}) => resolveSingleAgentWorkingDirectoryForSessionRuntimeInternal(
|
||||
this,
|
||||
sessionKey,
|
||||
provider: provider,
|
||||
);
|
||||
|
||||
bool singleAgentProviderRequiresLocalPathInternal(
|
||||
SingleAgentProvider provider,
|
||||
) {
|
||||
final endpoint = resolveSingleAgentEndpointInternal(provider);
|
||||
if (endpoint == null) {
|
||||
return true;
|
||||
}
|
||||
final scheme = endpoint.scheme.trim().toLowerCase();
|
||||
if (scheme == 'wss' || scheme == 'https') {
|
||||
return false;
|
||||
}
|
||||
final host = endpoint.host.trim();
|
||||
if (host.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
final address = InternetAddress.tryParse(host);
|
||||
if (address != null) {
|
||||
return !(address.isLoopback || address.type == InternetAddressType.unix);
|
||||
}
|
||||
final normalizedHost = host.toLowerCase();
|
||||
if (normalizedHost == 'localhost') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
) => singleAgentProviderRequiresLocalPathRuntimeInternal(this, provider);
|
||||
|
||||
void registerCodexExternalProviderInternal() {
|
||||
runtimeCoordinatorInternal.registerExternalCodeAgent(
|
||||
@ -603,117 +478,19 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
);
|
||||
}
|
||||
|
||||
CodeAgentNodeState buildCodeAgentNodeStateInternal() {
|
||||
return CodeAgentNodeState(
|
||||
selectedAgentId: agentsControllerInternal.selectedAgentId,
|
||||
gatewayConnected: runtimeInternal.isConnected,
|
||||
executionTarget: currentAssistantExecutionTarget,
|
||||
runtimeMode: effectiveCodeAgentRuntimeMode,
|
||||
bridgeEnabled: isCodexBridgeEnabledInternal,
|
||||
bridgeState: codexCooperationStateInternal.name,
|
||||
preferredProviderId: 'codex',
|
||||
resolvedCodexCliPath: resolvedCodexCliPathInternal,
|
||||
configuredCodexCliPath: configuredCodexCliPath,
|
||||
);
|
||||
}
|
||||
CodeAgentNodeState buildCodeAgentNodeStateInternal() =>
|
||||
buildCodeAgentNodeStateRuntimeInternal(this);
|
||||
|
||||
GatewayMode bridgeGatewayModeInternal() {
|
||||
if (!runtimeInternal.isConnected) {
|
||||
return GatewayMode.offline;
|
||||
}
|
||||
return switch (currentAssistantExecutionTarget) {
|
||||
AssistantExecutionTarget.singleAgent => GatewayMode.offline,
|
||||
AssistantExecutionTarget.local => GatewayMode.local,
|
||||
AssistantExecutionTarget.remote => GatewayMode.remote,
|
||||
};
|
||||
}
|
||||
GatewayMode bridgeGatewayModeInternal() =>
|
||||
bridgeGatewayModeRuntimeInternal(this);
|
||||
|
||||
Future<void> ensureCodexGatewayRegistrationInternal() async {
|
||||
if (!isCodexBridgeEnabledInternal) {
|
||||
return;
|
||||
}
|
||||
Future<void> ensureCodexGatewayRegistrationInternal() =>
|
||||
ensureCodexGatewayRegistrationRuntimeInternal(this);
|
||||
|
||||
if (!runtimeInternal.isConnected) {
|
||||
codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
|
||||
codeAgentBridgeRegistryInternal.clearRegistration();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
void clearCodexGatewayRegistrationInternal() =>
|
||||
clearCodexGatewayRegistrationRuntimeInternal(this);
|
||||
|
||||
if (codeAgentBridgeRegistryInternal.isRegistered) {
|
||||
codexCooperationStateInternal = CodexCooperationState.registered;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final dispatch = codeAgentNodeOrchestratorInternal.buildGatewayDispatch(
|
||||
buildCodeAgentNodeStateInternal(),
|
||||
);
|
||||
await codeAgentBridgeRegistryInternal.register(
|
||||
agentType: 'code-agent-bridge',
|
||||
name: 'XWorkmate Codex Bridge',
|
||||
version: kAppVersion,
|
||||
transport: 'stdio-bridge',
|
||||
capabilities: const <AgentCapability>[
|
||||
AgentCapability(
|
||||
name: 'chat',
|
||||
description: 'Bridge external Codex CLI chat turns.',
|
||||
),
|
||||
AgentCapability(
|
||||
name: 'code-edit',
|
||||
description: 'Bridge code editing tasks through Codex CLI.',
|
||||
),
|
||||
AgentCapability(
|
||||
name: 'memory-sync',
|
||||
description: 'Coordinate memory sync through OpenClaw Gateway.',
|
||||
),
|
||||
],
|
||||
metadata: <String, dynamic>{
|
||||
...dispatch.metadata,
|
||||
'providerId': 'codex',
|
||||
'runtimeMode': effectiveCodeAgentRuntimeMode.name,
|
||||
'gatewayMode': bridgeGatewayModeInternal().name,
|
||||
'binaryConfigured': (resolvedCodexCliPath ?? configuredCodexCliPath)
|
||||
.trim()
|
||||
.isNotEmpty,
|
||||
'capabilities': const <String>[
|
||||
'chat',
|
||||
'code-edit',
|
||||
'gateway-bridge',
|
||||
'memory-sync',
|
||||
],
|
||||
},
|
||||
);
|
||||
codexCooperationStateInternal = CodexCooperationState.registered;
|
||||
codexBridgeErrorInternal = null;
|
||||
} catch (error) {
|
||||
codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
|
||||
codexBridgeErrorInternal = error.toString();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearCodexGatewayRegistrationInternal() {
|
||||
codeAgentBridgeRegistryInternal.clearRegistration();
|
||||
if (isCodexBridgeEnabledInternal) {
|
||||
codexCooperationStateInternal = CodexCooperationState.bridgeOnly;
|
||||
} else {
|
||||
codexCooperationStateInternal = CodexCooperationState.notStarted;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void recomputeTasksInternal() {
|
||||
tasksControllerInternal.recompute(
|
||||
sessions: sessions,
|
||||
cronJobs: cronJobsControllerInternal.items,
|
||||
currentSessionKey: sessionsControllerInternal.currentSessionKey,
|
||||
hasPendingRun: hasAssistantPendingRun,
|
||||
activeAgentName: agentsControllerInternal.activeAgentName,
|
||||
);
|
||||
}
|
||||
void recomputeTasksInternal() => recomputeTasksRuntimeInternal(this);
|
||||
|
||||
void attachChildListenersInternal() {
|
||||
runtimeCoordinatorInternal.addListener(relayChildChangeInternal);
|
||||
|
||||
@ -45,6 +45,7 @@ import 'app_controller_desktop_settings_runtime.dart';
|
||||
import 'app_controller_desktop_thread_storage.dart';
|
||||
import 'app_controller_desktop_skill_permissions.dart';
|
||||
import 'app_controller_desktop_runtime_helpers.dart';
|
||||
import 'app_controller_desktop_thread_sessions_collaboration_impl.dart';
|
||||
|
||||
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
|
||||
extension AppControllerDesktopThreadSessions on AppController {
|
||||
@ -430,293 +431,45 @@ extension AppControllerDesktopThreadSessions on AppController {
|
||||
|
||||
String get assistantConnectionStatusLabel =>
|
||||
currentAssistantConnectionState.primaryLabel;
|
||||
String get assistantConnectionTargetLabel =>
|
||||
currentAssistantConnectionState.detailLabel;
|
||||
|
||||
String get assistantConnectionTargetLabel {
|
||||
return currentAssistantConnectionState.detailLabel;
|
||||
}
|
||||
|
||||
Future<String> loadAiGatewayApiKey() async {
|
||||
return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
||||
}
|
||||
|
||||
Future<void> saveMultiAgentConfig(MultiAgentConfig config) async {
|
||||
final resolved = resolveMultiAgentConfigInternal(
|
||||
settings.copyWith(multiAgent: config),
|
||||
);
|
||||
await AppControllerDesktopSettings(this).saveSettings(
|
||||
settings.copyWith(multiAgent: resolved),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await refreshMultiAgentMounts(sync: resolved.autoSync);
|
||||
}
|
||||
|
||||
Future<void> refreshMultiAgentMounts({bool sync = false}) async {
|
||||
await refreshAcpCapabilitiesInternal(persistMountTargets: true);
|
||||
}
|
||||
|
||||
Future<String> loadAiGatewayApiKey() =>
|
||||
loadAiGatewayApiKeyThreadSessionInternal(this);
|
||||
Future<void> saveMultiAgentConfig(MultiAgentConfig config) =>
|
||||
saveMultiAgentConfigThreadSessionInternal(this, config);
|
||||
Future<void> refreshMultiAgentMounts({bool sync = false}) =>
|
||||
refreshMultiAgentMountsThreadSessionInternal(this, sync: sync);
|
||||
Future<void> runMultiAgentCollaboration({
|
||||
required String rawPrompt,
|
||||
required String composedPrompt,
|
||||
required List<CollaborationAttachment> attachments,
|
||||
required List<String> selectedSkillLabels,
|
||||
}) async {
|
||||
final sessionKey = currentSessionKey.trim().isEmpty
|
||||
? 'main'
|
||||
: currentSessionKey;
|
||||
await enqueueThreadTurnInternal<void>(sessionKey, () async {
|
||||
final aiGatewayApiKey = await loadAiGatewayApiKey();
|
||||
multiAgentRunPendingInternal = true;
|
||||
appendLocalSessionMessageInternal(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: nextLocalMessageIdInternal(),
|
||||
role: 'user',
|
||||
text: rawPrompt,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
);
|
||||
recomputeTasksInternal();
|
||||
try {
|
||||
final taskStream = gatewayAcpClientInternal.runMultiAgent(
|
||||
GatewayAcpMultiAgentRequest(
|
||||
sessionId: sessionKey,
|
||||
threadId: sessionKey,
|
||||
prompt: composedPrompt,
|
||||
workingDirectory:
|
||||
assistantWorkingDirectoryForSessionInternal(sessionKey) ??
|
||||
Directory.current.path,
|
||||
attachments: attachments,
|
||||
selectedSkills: selectedSkillLabels,
|
||||
aiGatewayBaseUrl: aiGatewayUrl,
|
||||
aiGatewayApiKey: aiGatewayApiKey,
|
||||
resumeSession: true,
|
||||
),
|
||||
);
|
||||
await for (final event in taskStream) {
|
||||
if (event.type == 'result') {
|
||||
final success = event.data['success'] == true;
|
||||
final finalScore = event.data['finalScore'];
|
||||
final iterations = event.data['iterations'];
|
||||
appendLocalSessionMessageInternal(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: nextLocalMessageIdInternal(),
|
||||
role: 'assistant',
|
||||
text: success
|
||||
? appText(
|
||||
'多 Agent 协作完成,评分 ${finalScore ?? '-'},迭代 ${iterations ?? 0} 次。',
|
||||
'Multi-agent collaboration completed with score ${finalScore ?? '-'} after ${iterations ?? 0} iteration(s).',
|
||||
)
|
||||
: appText(
|
||||
'多 Agent 协作失败:${event.data['error'] ?? event.message}',
|
||||
'Multi-agent collaboration failed: ${event.data['error'] ?? event.message}',
|
||||
),
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: !success,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
appendLocalSessionMessageInternal(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: nextLocalMessageIdInternal(),
|
||||
role: 'assistant',
|
||||
text: event.message,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: event.title,
|
||||
stopReason: null,
|
||||
pending: event.pending,
|
||||
error: event.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on GatewayAcpException catch (error) {
|
||||
appendLocalSessionMessageInternal(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: nextLocalMessageIdInternal(),
|
||||
role: 'assistant',
|
||||
text: appText(
|
||||
'多 Agent 协作不可用(Gateway ACP):${error.message}',
|
||||
'Multi-agent collaboration is unavailable (Gateway ACP): ${error.message}',
|
||||
),
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: 'Multi-Agent',
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
appendLocalSessionMessageInternal(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: nextLocalMessageIdInternal(),
|
||||
role: 'assistant',
|
||||
text: error.toString(),
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: 'Multi-Agent',
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: true,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
multiAgentRunPendingInternal = false;
|
||||
recomputeTasksInternal();
|
||||
notifyIfActiveInternal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> openOnlineWorkspace() async {
|
||||
const url = 'https://www.svc.plus/Xworkmate';
|
||||
try {
|
||||
if (Platform.isMacOS) {
|
||||
await Process.run('open', [url]);
|
||||
return;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
await Process.run('cmd', ['/c', 'start', '', url]);
|
||||
return;
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
await Process.run('xdg-open', [url]);
|
||||
}
|
||||
} catch (_) {
|
||||
// Best effort only. Do not surface a blocking error from a convenience link.
|
||||
}
|
||||
}
|
||||
|
||||
List<String> get aiGatewayModelChoices {
|
||||
return aiGatewayConversationModelChoices;
|
||||
}
|
||||
|
||||
List<String> get connectedGatewayModelChoices {
|
||||
if (connection.status != RuntimeConnectionStatus.connected) {
|
||||
return const <String>[];
|
||||
}
|
||||
return modelsControllerInternal.items
|
||||
.map((item) => item.id.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> get assistantModelChoices {
|
||||
return assistantModelChoicesForSessionInternal(currentSessionKey);
|
||||
}
|
||||
|
||||
List<String> assistantModelChoicesForSessionInternal(String sessionKey) {
|
||||
final target = assistantExecutionTargetForSession(sessionKey);
|
||||
if (target == AssistantExecutionTarget.singleAgent) {
|
||||
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
|
||||
return aiGatewayConversationModelChoices;
|
||||
}
|
||||
final selectedModel =
|
||||
assistantThreadRecordsInternal[normalizedAssistantSessionKeyInternal(
|
||||
sessionKey,
|
||||
)]
|
||||
?.assistantModelId
|
||||
.trim();
|
||||
if (selectedModel?.isNotEmpty == true) {
|
||||
return <String>[selectedModel!];
|
||||
}
|
||||
return const <String>[];
|
||||
}
|
||||
final runtimeModels = connectedGatewayModelChoices;
|
||||
if (runtimeModels.isNotEmpty) {
|
||||
return runtimeModels;
|
||||
}
|
||||
final resolved = resolvedDefaultModel.trim();
|
||||
if (resolved.isNotEmpty) {
|
||||
return <String>[resolved];
|
||||
}
|
||||
final localDefault = settings.ollamaLocal.defaultModel.trim();
|
||||
if (localDefault.isNotEmpty) {
|
||||
return <String>[localDefault];
|
||||
}
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
String get resolvedDefaultModel {
|
||||
final current = settings.defaultModel.trim();
|
||||
if (current.isNotEmpty) {
|
||||
return current;
|
||||
}
|
||||
final localDefault = settings.ollamaLocal.defaultModel.trim();
|
||||
if (localDefault.isNotEmpty) {
|
||||
return localDefault;
|
||||
}
|
||||
final runtimeModels = connectedGatewayModelChoices;
|
||||
if (runtimeModels.isNotEmpty) {
|
||||
return runtimeModels.first;
|
||||
}
|
||||
final aiGatewayChoices = aiGatewayConversationModelChoices;
|
||||
if (aiGatewayChoices.isNotEmpty) {
|
||||
return aiGatewayChoices.first;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
bool get canQuickConnectGateway {
|
||||
final target = currentAssistantExecutionTarget;
|
||||
if (target == AssistantExecutionTarget.singleAgent) {
|
||||
return false;
|
||||
}
|
||||
final profile = gatewayProfileForAssistantExecutionTargetInternal(target);
|
||||
if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
final host = profile.host.trim();
|
||||
if (host.isEmpty || profile.port <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (profile.mode == RuntimeConnectionMode.local) {
|
||||
return true;
|
||||
}
|
||||
final defaults = switch (target) {
|
||||
AssistantExecutionTarget.singleAgent =>
|
||||
GatewayConnectionProfile.emptySlot(index: kGatewayRemoteProfileIndex),
|
||||
AssistantExecutionTarget.local =>
|
||||
GatewayConnectionProfile.defaultsLocal(),
|
||||
AssistantExecutionTarget.remote =>
|
||||
GatewayConnectionProfile.defaultsRemote(),
|
||||
};
|
||||
return hasStoredGatewayCredential ||
|
||||
host != defaults.host ||
|
||||
profile.port != defaults.port ||
|
||||
profile.tls != defaults.tls ||
|
||||
profile.mode != defaults.mode;
|
||||
}
|
||||
|
||||
String joinConnectionPartsInternal(List<String> parts) {
|
||||
final normalized = parts
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
return normalized.join(' · ');
|
||||
}
|
||||
|
||||
String gatewayAddressLabelInternal(GatewayConnectionProfile profile) {
|
||||
final host = profile.host.trim();
|
||||
if (host.isEmpty || profile.port <= 0) {
|
||||
return appText('未连接目标', 'No target');
|
||||
}
|
||||
return '$host:${profile.port}';
|
||||
}
|
||||
}) => runMultiAgentCollaborationThreadSessionInternal(
|
||||
this,
|
||||
rawPrompt: rawPrompt,
|
||||
composedPrompt: composedPrompt,
|
||||
attachments: attachments,
|
||||
selectedSkillLabels: selectedSkillLabels,
|
||||
);
|
||||
Future<void> openOnlineWorkspace() =>
|
||||
openOnlineWorkspaceThreadSessionInternal(this);
|
||||
List<String> get aiGatewayModelChoices =>
|
||||
aiGatewayModelChoicesThreadSessionInternal(this);
|
||||
List<String> get connectedGatewayModelChoices =>
|
||||
connectedGatewayModelChoicesThreadSessionInternal(this);
|
||||
List<String> get assistantModelChoices =>
|
||||
assistantModelChoicesThreadSessionInternal(this);
|
||||
List<String> assistantModelChoicesForSessionInternal(String sessionKey) =>
|
||||
assistantModelChoicesForSessionThreadSessionInternal(this, sessionKey);
|
||||
String get resolvedDefaultModel =>
|
||||
resolvedDefaultModelThreadSessionInternal(this);
|
||||
bool get canQuickConnectGateway =>
|
||||
canQuickConnectGatewayThreadSessionInternal(this);
|
||||
String joinConnectionPartsInternal(List<String> parts) =>
|
||||
joinConnectionPartsThreadSessionInternal(parts);
|
||||
String gatewayAddressLabelInternal(GatewayConnectionProfile profile) =>
|
||||
gatewayAddressLabelThreadSessionInternal(profile);
|
||||
|
||||
List<SecretReferenceEntry> get secretReferences =>
|
||||
settingsControllerInternal.buildSecretReferences();
|
||||
|
||||
@ -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
870
lib/features/assistant/assistant_page_state_actions.dart
Normal file
870
lib/features/assistant/assistant_page_state_actions.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
430
lib/features/assistant/assistant_page_state_closure.dart
Normal file
430
lib/features/assistant/assistant_page_state_closure.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import 'secure_config_store.dart';
|
||||
import 'runtime_controllers_gateway.dart';
|
||||
import 'runtime_controllers_entities.dart';
|
||||
import 'runtime_controllers_derived_tasks.dart';
|
||||
import 'runtime_controllers_settings_connectivity_impl.dart';
|
||||
|
||||
class SettingsController extends ChangeNotifier {
|
||||
SettingsController(this.storeInternal);
|
||||
@ -279,266 +280,60 @@ class SettingsController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String> testOllamaConnection({required bool cloud}) async {
|
||||
return testOllamaConnectionDraft(
|
||||
cloud: cloud,
|
||||
localConfig: snapshotInternal.ollamaLocal,
|
||||
cloudConfig: snapshotInternal.ollamaCloud,
|
||||
);
|
||||
}
|
||||
Future<String> testOllamaConnection({required bool cloud}) =>
|
||||
testOllamaConnectionSettingsInternal(this, cloud: cloud);
|
||||
|
||||
Future<String> testOllamaConnectionDraft({
|
||||
required bool cloud,
|
||||
required OllamaLocalConfig localConfig,
|
||||
required OllamaCloudConfig cloudConfig,
|
||||
String apiKeyOverride = '',
|
||||
}) async {
|
||||
final base = cloud
|
||||
? cloudConfig.baseUrl.trim()
|
||||
: localConfig.endpoint.trim();
|
||||
if (base.isEmpty) {
|
||||
final message = 'Missing endpoint';
|
||||
ollamaStatusInternal = message;
|
||||
notifyListeners();
|
||||
return message;
|
||||
}
|
||||
final cloudApiKey = apiKeyOverride.trim().isNotEmpty
|
||||
? apiKeyOverride.trim()
|
||||
: (await storeInternal.loadOllamaCloudApiKey())?.trim() ?? '';
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags',
|
||||
);
|
||||
final response = await simpleGetInternal(
|
||||
uri,
|
||||
headers: cloud
|
||||
? <String, String>{
|
||||
if (cloudApiKey.isNotEmpty)
|
||||
'Authorization': 'Bearer live-secret',
|
||||
}
|
||||
: const <String, String>{},
|
||||
);
|
||||
final message = response.statusCode < 500
|
||||
? 'Reachable (${response.statusCode})'
|
||||
: 'Unhealthy (${response.statusCode})';
|
||||
ollamaStatusInternal = message;
|
||||
notifyListeners();
|
||||
return message;
|
||||
} catch (error) {
|
||||
final message = 'Failed: $error';
|
||||
ollamaStatusInternal = message;
|
||||
notifyListeners();
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}) => testOllamaConnectionDraftSettingsInternal(
|
||||
this,
|
||||
cloud: cloud,
|
||||
localConfig: localConfig,
|
||||
cloudConfig: cloudConfig,
|
||||
apiKeyOverride: apiKeyOverride,
|
||||
);
|
||||
|
||||
Future<String> testVaultConnection() async {
|
||||
return testVaultConnectionDraft(snapshotInternal.vault);
|
||||
}
|
||||
Future<String> testVaultConnection() =>
|
||||
testVaultConnectionSettingsInternal(this);
|
||||
|
||||
Future<String> testVaultConnectionDraft(
|
||||
VaultConfig profile, {
|
||||
String tokenOverride = '',
|
||||
}) async {
|
||||
final address = profile.address.trim();
|
||||
if (address.isEmpty) {
|
||||
const message = 'Missing address';
|
||||
vaultStatusInternal = message;
|
||||
notifyListeners();
|
||||
return message;
|
||||
}
|
||||
try {
|
||||
final uri = Uri.parse(
|
||||
'$address${address.endsWith('/') ? '' : '/'}v1/sys/health',
|
||||
);
|
||||
final headers = <String, String>{
|
||||
if (profile.namespace.trim().isNotEmpty)
|
||||
'X-Vault-Namespace': profile.namespace.trim(),
|
||||
};
|
||||
final token = tokenOverride.trim().isNotEmpty
|
||||
? tokenOverride.trim()
|
||||
: (await storeInternal.loadVaultToken())?.trim() ?? '';
|
||||
if (token.trim().isNotEmpty) {
|
||||
headers['X-Vault-Token'] = token.trim();
|
||||
}
|
||||
final response = await simpleGetInternal(uri, headers: headers);
|
||||
final message = response.statusCode < 500
|
||||
? 'Reachable (${response.statusCode})'
|
||||
: 'Unhealthy (${response.statusCode})';
|
||||
vaultStatusInternal = message;
|
||||
notifyListeners();
|
||||
return message;
|
||||
} catch (error) {
|
||||
final message = 'Failed: $error';
|
||||
vaultStatusInternal = message;
|
||||
notifyListeners();
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}) => testVaultConnectionDraftSettingsInternal(
|
||||
this,
|
||||
profile,
|
||||
tokenOverride: tokenOverride,
|
||||
);
|
||||
|
||||
Future<AiGatewayProfile> syncAiGatewayCatalog(
|
||||
AiGatewayProfile profile, {
|
||||
String apiKeyOverride = '',
|
||||
}) async {
|
||||
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal(
|
||||
profile.baseUrl,
|
||||
);
|
||||
if (normalizedBaseUrl == null) {
|
||||
final next = profile.copyWith(
|
||||
syncState: 'invalid',
|
||||
syncMessage: 'Missing LLM API Endpoint',
|
||||
);
|
||||
aiGatewayStatusInternal = next.syncMessage;
|
||||
snapshotInternal = snapshotInternal.copyWith(aiGateway: next);
|
||||
await storeInternal.saveSettingsSnapshot(snapshotInternal);
|
||||
notifyListeners();
|
||||
return next;
|
||||
}
|
||||
final apiKey = apiKeyOverride.trim().isNotEmpty
|
||||
? apiKeyOverride.trim()
|
||||
: (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
||||
if (apiKey.isEmpty) {
|
||||
final next = profile.copyWith(
|
||||
baseUrl: normalizedBaseUrl.toString(),
|
||||
syncState: 'invalid',
|
||||
syncMessage: 'Missing LLM API Token',
|
||||
);
|
||||
aiGatewayStatusInternal = next.syncMessage;
|
||||
snapshotInternal = snapshotInternal.copyWith(aiGateway: next);
|
||||
await storeInternal.saveSettingsSnapshot(snapshotInternal);
|
||||
notifyListeners();
|
||||
return next;
|
||||
}
|
||||
try {
|
||||
final models = await loadAiGatewayModels(
|
||||
profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()),
|
||||
apiKeyOverride: apiKey,
|
||||
);
|
||||
final availableModels = models
|
||||
.map((item) => item.id)
|
||||
.toList(growable: false);
|
||||
final retainedSelected = profile.selectedModels
|
||||
.where(availableModels.contains)
|
||||
.toList(growable: false);
|
||||
final selectedModels = retainedSelected.isNotEmpty
|
||||
? retainedSelected
|
||||
: availableModels.take(5).toList(growable: false);
|
||||
final currentDefaultModel = snapshotInternal.defaultModel.trim();
|
||||
final resolvedDefaultModel = selectedModels.contains(currentDefaultModel)
|
||||
? currentDefaultModel
|
||||
: selectedModels.isNotEmpty
|
||||
? selectedModels.first
|
||||
: availableModels.isNotEmpty
|
||||
? availableModels.first
|
||||
: '';
|
||||
final next = profile.copyWith(
|
||||
baseUrl: normalizedBaseUrl.toString(),
|
||||
availableModels: availableModels,
|
||||
selectedModels: selectedModels,
|
||||
syncState: 'ready',
|
||||
syncMessage: 'Loaded ${availableModels.length} model(s)',
|
||||
);
|
||||
aiGatewayStatusInternal = 'Ready (${availableModels.length})';
|
||||
snapshotInternal = snapshotInternal.copyWith(
|
||||
aiGateway: next,
|
||||
defaultModel: resolvedDefaultModel,
|
||||
);
|
||||
await storeInternal.saveSettingsSnapshot(snapshotInternal);
|
||||
await reloadDerivedStateInternal();
|
||||
notifyListeners();
|
||||
return next;
|
||||
} catch (error) {
|
||||
final next = profile.copyWith(
|
||||
baseUrl: normalizedBaseUrl.toString(),
|
||||
syncState: 'error',
|
||||
syncMessage: networkErrorLabelInternal(error),
|
||||
);
|
||||
aiGatewayStatusInternal = next.syncMessage;
|
||||
snapshotInternal = snapshotInternal.copyWith(aiGateway: next);
|
||||
await storeInternal.saveSettingsSnapshot(snapshotInternal);
|
||||
notifyListeners();
|
||||
return next;
|
||||
}
|
||||
}
|
||||
}) => syncAiGatewayCatalogSettingsInternal(
|
||||
this,
|
||||
profile,
|
||||
apiKeyOverride: apiKeyOverride,
|
||||
);
|
||||
|
||||
Future<AiGatewayConnectionCheck> testAiGatewayConnection(
|
||||
AiGatewayProfile profile, {
|
||||
String apiKeyOverride = '',
|
||||
}) async {
|
||||
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal(
|
||||
profile.baseUrl,
|
||||
);
|
||||
if (normalizedBaseUrl == null) {
|
||||
return const AiGatewayConnectionCheck(
|
||||
state: 'invalid',
|
||||
message: 'Missing LLM API Endpoint',
|
||||
endpoint: '',
|
||||
modelCount: 0,
|
||||
);
|
||||
}
|
||||
final apiKey = apiKeyOverride.trim().isNotEmpty
|
||||
? apiKeyOverride.trim()
|
||||
: (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
||||
final endpoint = aiGatewayModelsUriInternal(normalizedBaseUrl).toString();
|
||||
if (apiKey.isEmpty) {
|
||||
return AiGatewayConnectionCheck(
|
||||
state: 'invalid',
|
||||
message: 'Missing LLM API Token',
|
||||
endpoint: endpoint,
|
||||
modelCount: 0,
|
||||
);
|
||||
}
|
||||
try {
|
||||
final models = await requestAiGatewayModelsInternal(
|
||||
uri: aiGatewayModelsUriInternal(normalizedBaseUrl),
|
||||
apiKey: apiKey,
|
||||
);
|
||||
if (models.isEmpty) {
|
||||
return AiGatewayConnectionCheck(
|
||||
state: 'empty',
|
||||
message: 'Authenticated but no models were returned',
|
||||
endpoint: endpoint,
|
||||
modelCount: 0,
|
||||
);
|
||||
}
|
||||
return AiGatewayConnectionCheck(
|
||||
state: 'ready',
|
||||
message: 'Authenticated · ${models.length} model(s) available',
|
||||
endpoint: endpoint,
|
||||
modelCount: models.length,
|
||||
);
|
||||
} catch (error) {
|
||||
return AiGatewayConnectionCheck(
|
||||
state: 'error',
|
||||
message: networkErrorLabelInternal(error),
|
||||
endpoint: endpoint,
|
||||
modelCount: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}) => testAiGatewayConnectionSettingsInternal(
|
||||
this,
|
||||
profile,
|
||||
apiKeyOverride: apiKeyOverride,
|
||||
);
|
||||
|
||||
Future<List<GatewayModelSummary>> loadAiGatewayModels({
|
||||
AiGatewayProfile? profile,
|
||||
String apiKeyOverride = '',
|
||||
}) async {
|
||||
final activeProfile = profile ?? snapshotInternal.aiGateway;
|
||||
final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal(
|
||||
activeProfile.baseUrl,
|
||||
);
|
||||
if (normalizedBaseUrl == null) {
|
||||
return const <GatewayModelSummary>[];
|
||||
}
|
||||
final apiKey = apiKeyOverride.trim().isNotEmpty
|
||||
? apiKeyOverride.trim()
|
||||
: (await storeInternal.loadAiGatewayApiKey())?.trim() ?? '';
|
||||
if (apiKey.isEmpty) {
|
||||
return const <GatewayModelSummary>[];
|
||||
}
|
||||
return requestAiGatewayModelsInternal(
|
||||
uri: aiGatewayModelsUriInternal(normalizedBaseUrl),
|
||||
apiKey: apiKey,
|
||||
);
|
||||
}
|
||||
}) => loadAiGatewayModelsSettingsInternal(
|
||||
this,
|
||||
profile: profile,
|
||||
apiKeyOverride: apiKeyOverride,
|
||||
);
|
||||
|
||||
List<SecretReferenceEntry> buildSecretReferences() {
|
||||
final entries = <SecretReferenceEntry>[
|
||||
|
||||
295
lib/runtime/runtime_controllers_settings_connectivity_impl.dart
Normal file
295
lib/runtime/runtime_controllers_settings_connectivity_impl.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -17,16 +17,20 @@ void main() {
|
||||
// Baseline cap for legacy oversized closure; tighten after T3.
|
||||
'lib/runtime/gateway_runtime_core.dart': 950,
|
||||
'lib/runtime/gateway_runtime_helpers.dart': 800,
|
||||
// Baseline cap for legacy oversized closure; tighten after T3.
|
||||
'lib/runtime/runtime_controllers_settings.dart': 960,
|
||||
// Tightened after T3 closure convergence.
|
||||
'lib/runtime/runtime_controllers_settings.dart': 800,
|
||||
'lib/features/settings/settings_page_gateway.dart': 800,
|
||||
'test/runtime/app_controller_assistant_flow_suite.dart': 800,
|
||||
'test/runtime/app_controller_thread_skills_suite.dart': 800,
|
||||
|
||||
// Tightened in T2 after assistant/composer closure split.
|
||||
'lib/features/assistant/assistant_page_main.dart': 2200,
|
||||
'lib/app/app_controller_desktop_runtime_helpers.dart': 950,
|
||||
'lib/app/app_controller_desktop_thread_sessions.dart': 1050,
|
||||
// Tightened in T2/T3 after assistant + app/runtime closure split.
|
||||
'lib/features/assistant/assistant_page_main.dart': 1000,
|
||||
'lib/app/app_controller_desktop_runtime_helpers.dart': 800,
|
||||
'lib/app/app_controller_desktop_thread_sessions.dart': 800,
|
||||
'lib/app/app_controller_desktop_runtime_coordination_impl.dart': 800,
|
||||
'lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart':
|
||||
800,
|
||||
'lib/runtime/runtime_controllers_settings_connectivity_impl.dart': 800,
|
||||
};
|
||||
final violations = <String>[];
|
||||
for (final entry in targets.entries) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user