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