Add top-level Auto task dialog mode

This commit is contained in:
Haitao Pan 2026-04-03 19:55:47 +08:00
parent e018fc15dd
commit 283dcdd43c
35 changed files with 252 additions and 97 deletions

View File

@ -110,9 +110,6 @@ func resolveExecution(req Request, prefs memory.Preferences) (string, string) {
}
prompt := normalize(req.Prompt)
if looksComplex(prompt) {
return ExecutionTargetMultiAgent, EndpointTargetSingleAgent
}
if looksOnline(prompt) {
return ExecutionTargetGatewayChat, normalizeGatewayTarget(req.PreferredGatewayTarget)
}
@ -168,13 +165,6 @@ func looksOnline(prompt string) bool {
})
}
func looksComplex(prompt string) bool {
return containsAny(prompt, []string{
"review", "qa", "汇总", "复审", "审阅", "多步骤", "多工序", "multi-step",
"multiple deliverables", "multiple artifacts", "总结并对比", "整理并审查",
})
}
func containsAny(haystack string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(haystack, normalize(needle)) {

View File

@ -66,7 +66,7 @@ func TestResolveAutoOnlineTaskToGateway(t *testing.T) {
}
}
func TestResolveComplexTaskUpgradesToMultiAgent(t *testing.T) {
func TestResolveComplexTaskStaysWithinAutoSingleAgentAndGatewayLanes(t *testing.T) {
resolver := Resolver{
SkillFinder: skills.StaticFinder{},
MemoryService: memory.Service{},
@ -76,7 +76,7 @@ func TestResolveComplexTaskUpgradesToMultiAgent(t *testing.T) {
Prompt: "analyze these files, review the output, and summarize multiple deliverables",
})
if result.ResolvedExecutionTarget != ExecutionTargetMultiAgent {
t.Fatalf("expected multi-agent route, got %#v", result)
if result.ResolvedExecutionTarget != ExecutionTargetSingleAgent {
t.Fatalf("expected single-agent route, got %#v", result)
}
}

View File

@ -541,7 +541,8 @@ class AppController extends ChangeNotifier {
int get activeGatewayProfileIndexInternal {
final target = currentAssistantExecutionTarget;
if (target == AssistantExecutionTarget.singleAgent) {
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
return kGatewayRemoteProfileIndex;
}
return gatewayProfileIndexForExecutionTargetInternal(target);

View File

@ -264,6 +264,7 @@ GatewayMode bridgeGatewayModeRuntimeInternal(AppController controller) {
return GatewayMode.offline;
}
return switch (controller.currentAssistantExecutionTarget) {
AssistantExecutionTarget.auto => GatewayMode.offline,
AssistantExecutionTarget.singleAgent => GatewayMode.offline,
AssistantExecutionTarget.local => GatewayMode.local,
AssistantExecutionTarget.remote => GatewayMode.remote,

View File

@ -683,7 +683,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
) {
return switch (mode) {
RuntimeConnectionMode.unconfigured =>
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.auto,
RuntimeConnectionMode.local => AssistantExecutionTarget.local,
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote,
};
@ -693,6 +693,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.auto => settings.primaryLocalGatewayProfile,
AssistantExecutionTarget.local => settings.primaryLocalGatewayProfile,
AssistantExecutionTarget.remote => settings.primaryRemoteGatewayProfile,
AssistantExecutionTarget.singleAgent => throw StateError(
@ -705,6 +706,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.auto => kGatewayLocalProfileIndex,
AssistantExecutionTarget.local => kGatewayLocalProfileIndex,
AssistantExecutionTarget.remote => kGatewayRemoteProfileIndex,
AssistantExecutionTarget.singleAgent => throw StateError(

View File

@ -248,10 +248,22 @@ extension AppControllerDesktopSettings on AppController {
defaults.primaryRemoteGatewayProfile.selectedAgentId,
);
modelsControllerInternal.restoreFromSettings(defaults.aiGateway);
initializeAssistantThreadContext(
'main',
executionTarget: defaults.assistantExecutionTarget,
messageViewMode: AssistantMessageViewMode.rendered,
singleAgentProvider: SingleAgentProvider.auto,
);
await setCurrentAssistantSessionKeyInternal(
'main',
persistSelection: false,
);
assistantThreadRecordsInternal.removeWhere((key, _) => key != 'main');
assistantThreadMessagesInternal.removeWhere((key, _) => key != 'main');
await flushAssistantThreadPersistenceInternal();
await storeInternal.saveTaskThreads(
assistantThreadRecordsInternal.values.toList(growable: false),
);
chatControllerInternal.clear();
recomputeTasksInternal();
notifyListeners();

View File

@ -62,6 +62,7 @@ extension AppControllerDesktopSingleAgent on AppController {
return;
}
await enqueueThreadTurnInternal<void>(sessionKey, () async {
final sessionTarget = assistantExecutionTargetForSession(sessionKey);
final userText = trimmed.isEmpty ? 'See attached.' : trimmed;
appendAssistantThreadMessageInternal(
sessionKey,
@ -142,7 +143,9 @@ extension AppControllerDesktopSingleAgent on AppController {
}
return;
}
final effectiveProvider = provider ?? SingleAgentProvider.auto;
final effectiveProvider = sessionTarget == AssistantExecutionTarget.auto
? SingleAgentProvider.auto
: (provider ?? SingleAgentProvider.auto);
appendSingleAgentRuntimeStatusMessageInternal(
sessionKey,
@ -178,7 +181,9 @@ extension AppControllerDesktopSingleAgent on AppController {
target: AssistantExecutionTarget.singleAgent,
prompt: message,
workingDirectory: workingDirectory,
model: assistantModelForSession(sessionKey),
model: sessionTarget == AssistantExecutionTarget.auto
? ''
: assistantModelForSession(sessionKey),
thinking: thinking,
selectedSkills: selectedSkills,
inlineAttachments: attachments,
@ -202,6 +207,21 @@ extension AppControllerDesktopSingleAgent on AppController {
singleAgentRuntimeModelBySessionInternal[sessionKey] =
resolvedRuntimeModel;
}
final resolvedGatewayEntryState =
result.resolvedExecutionTarget == 'gateway-chat'
? (result.resolvedEndpointTarget.trim().isNotEmpty
? result.resolvedEndpointTarget.trim()
: AssistantExecutionTarget.local.promptValue)
: result.resolvedExecutionTarget == 'single-agent'
? AssistantExecutionTarget.singleAgent.promptValue
: (sessionTarget == AssistantExecutionTarget.auto
? AssistantExecutionTarget.auto.promptValue
: AssistantExecutionTarget.singleAgent.promptValue);
upsertTaskThreadInternal(
sessionKey,
gatewayEntryState: resolvedGatewayEntryState,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
final resolvedWorkspaceKind = result.resolvedWorkspaceRefKind;
final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim();
if (resolvedWorkspaceKind != null &&
@ -222,6 +242,11 @@ extension AppControllerDesktopSingleAgent on AppController {
sessionKey,
result.errorMessage,
);
upsertTaskThreadInternal(
sessionKey,
gatewayEntryState: 'only-chat',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await sendAiGatewayMessageInternal(
message,
thinking: thinking,
@ -801,9 +826,11 @@ extension AppControllerDesktopSingleAgent on AppController {
sessionKey,
);
final thread = assistantThreadRecordsInternal[normalizedSessionKey];
final preferredGatewayTarget = switch (assistantExecutionTargetForSession(
final sessionTarget = assistantExecutionTargetForSession(
normalizedSessionKey,
)) {
);
final preferredGatewayTarget = switch (sessionTarget) {
AssistantExecutionTarget.auto => 'local',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
AssistantExecutionTarget.singleAgent =>
@ -828,6 +855,9 @@ extension AppControllerDesktopSingleAgent on AppController {
.toList(growable: false);
final resolvedExplicitExecutionTarget =
sessionTarget == AssistantExecutionTarget.auto
? ''
:
explicitExecutionTarget?.trim().isNotEmpty == true
? explicitExecutionTarget!.trim()
: (thread?.hasExplicitExecutionTargetSelection ?? false)
@ -836,11 +866,16 @@ extension AppControllerDesktopSingleAgent on AppController {
)
: '';
final resolvedExplicitProviderId =
sessionTarget == AssistantExecutionTarget.auto
? ''
:
thread?.hasExplicitProviderSelection ?? false
? singleAgentProviderForSession(normalizedSessionKey).providerId
: '';
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
? assistantModelForSession(normalizedSessionKey)
? (sessionTarget == AssistantExecutionTarget.auto
? ''
: assistantModelForSession(normalizedSessionKey))
: '';
final resolvedExplicitSkills = thread?.hasExplicitSkillSelection ?? false
? selectedSkills
@ -872,6 +907,7 @@ extension AppControllerDesktopSingleAgent on AppController {
String _routingExecutionTargetValue(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.auto => 'singleAgent',
AssistantExecutionTarget.singleAgent => 'singleAgent',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',

View File

@ -357,6 +357,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
))
.copyWith(
executionMode: switch (nextExecutionTarget) {
AssistantExecutionTarget.auto => ThreadExecutionMode.auto,
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local =>

View File

@ -79,7 +79,8 @@ extension AppControllerDesktopThreadActions on AppController {
Future<void> connectSavedGateway() async {
final target = currentAssistantExecutionTarget;
if (target == AssistantExecutionTarget.singleAgent) {
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
return;
}
await AppControllerDesktopGateway(this).connectProfileInternal(
@ -247,8 +248,9 @@ extension AppControllerDesktopThreadActions on AppController {
List<String> selectedSkillLabels = const <String>[],
}) async {
final currentSessionKey = sessionsControllerInternal.currentSessionKey;
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
final currentTarget = assistantExecutionTargetForSession(currentSessionKey);
if (currentTarget == AssistantExecutionTarget.singleAgent ||
currentTarget == AssistantExecutionTarget.auto) {
await bootstrapThreadWorkspaceFromExecutionContextInternal(
currentSessionKey,
message,
@ -256,7 +258,7 @@ extension AppControllerDesktopThreadActions on AppController {
}
await ensureDesktopTaskThreadBindingInternal(
currentSessionKey,
executionTarget: assistantExecutionTargetForSession(currentSessionKey),
executionTarget: currentTarget,
);
final workspacePath = assistantWorkspacePathForSession(
currentSessionKey,
@ -276,7 +278,8 @@ extension AppControllerDesktopThreadActions on AppController {
recomputeTasksInternal();
throw error;
}
if (isSingleAgentMode) {
if (currentTarget == AssistantExecutionTarget.singleAgent ||
currentTarget == AssistantExecutionTarget.auto) {
await sendSingleAgentMessageInternal(
message,
thinking: thinking,
@ -321,7 +324,7 @@ extension AppControllerDesktopThreadActions on AppController {
GoAgentCoreSessionRequest(
sessionId: sessionKey,
threadId: sessionKey,
target: assistantExecutionTargetForSession(sessionKey),
target: currentTarget,
prompt: message,
workingDirectory: assistantWorkspacePathForSession(
sessionKey,

View File

@ -139,7 +139,8 @@ extension AppControllerDesktopThreadBinding on AppController {
required ThreadOwnerScope ownerScope,
WorkspaceBinding? existingBinding,
}) {
if (executionTarget == AssistantExecutionTarget.singleAgent) {
if (executionTarget == AssistantExecutionTarget.auto ||
executionTarget == AssistantExecutionTarget.singleAgent) {
if (existingBinding != null &&
existingBinding.workspacePath.trim().isNotEmpty) {
if (existingBinding.workspaceKind == WorkspaceKind.localFs) {
@ -207,10 +208,11 @@ extension AppControllerDesktopThreadBinding on AppController {
endpointId: '',
))
.copyWith(
executionMode: switch (executionTarget) {
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal,
executionMode: switch (executionTarget) {
AssistantExecutionTarget.auto => ThreadExecutionMode.auto,
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal,
AssistantExecutionTarget.remote =>
ThreadExecutionMode.gatewayRemote,
},

View File

@ -54,8 +54,9 @@ extension AppControllerDesktopThreadSessions on AppController {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
if (assistantExecutionTargetForSession(normalizedSessionKey) ==
AssistantExecutionTarget.singleAgent) {
final target = assistantExecutionTargetForSession(normalizedSessionKey);
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
return assistantImportedSkillsForSession(normalizedSessionKey).length;
}
return skills.length;
@ -96,7 +97,8 @@ extension AppControllerDesktopThreadSessions on AppController {
sessionKey,
);
final target = assistantExecutionTargetForSession(normalizedSessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) {
final recordModel =
assistantThreadRecordsInternal[normalizedSessionKey]
@ -137,8 +139,9 @@ extension AppControllerDesktopThreadSessions on AppController {
if (existing.isNotEmpty) {
return existing;
}
if (assistantExecutionTargetForSession(normalizedSessionKey) ==
AssistantExecutionTarget.singleAgent) {
final target = assistantExecutionTargetForSession(normalizedSessionKey);
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
return localThreadWorkspacePathInternal(normalizedSessionKey);
}
return '';
@ -337,10 +340,7 @@ extension AppControllerDesktopThreadSessions on AppController {
singleAgentShouldShowModelControlForSession(currentSessionKey);
List<SingleAgentProvider> get singleAgentProviderOptions =>
<SingleAgentProvider>[
SingleAgentProvider.auto,
...configuredSingleAgentProviders,
];
configuredSingleAgentProviders;
String singleAgentProviderLabelForSession(String sessionKey) {
return singleAgentProviderForSession(sessionKey).label;
@ -374,7 +374,8 @@ extension AppControllerDesktopThreadSessions on AppController {
sessionKey,
);
final target = assistantExecutionTargetForSession(normalizedSessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
final provider = singleAgentProviderForSession(normalizedSessionKey);
final resolvedProvider = singleAgentResolvedProviderForSession(
normalizedSessionKey,
@ -409,15 +410,21 @@ extension AppControllerDesktopThreadSessions on AppController {
'当前线程的外部 Agent ACP 连接尚未就绪。',
'The external Agent ACP connection for this thread is not ready yet.',
);
final primaryLabel = target == AssistantExecutionTarget.auto
? 'Auto'
: target.label;
final actualDetailPrefix = target == AssistantExecutionTarget.auto
? appText('当前: ', 'Current: ')
: '';
return AssistantThreadConnectionState(
executionTarget: target,
status: providerReady || fallbackReady
? RuntimeConnectionStatus.connected
: RuntimeConnectionStatus.offline,
primaryLabel: target.label,
primaryLabel: primaryLabel,
detailLabel: detail.isEmpty
? appText('未配置单机智能体', 'Single Agent is not configured')
: detail,
: '$actualDetailPrefix$detail',
ready: providerReady || fallbackReady,
pairingRequired: false,
gatewayTokenMissing: false,
@ -585,6 +592,7 @@ extension AppControllerDesktopThreadSessions on AppController {
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.auto => WorkspaceRefKind.localPath,
AssistantExecutionTarget.singleAgent => WorkspaceRefKind.localPath,
AssistantExecutionTarget.local ||
AssistantExecutionTarget.remote => WorkspaceRefKind.remotePath,

View File

@ -393,6 +393,7 @@ bool canQuickConnectGatewayThreadSessionInternal(AppController controller) {
return true;
}
final defaults = switch (target) {
AssistantExecutionTarget.auto => GatewayConnectionProfile.defaultsLocal(),
AssistantExecutionTarget.singleAgent => GatewayConnectionProfile.emptySlot(
index: kGatewayRemoteProfileIndex,
),

View File

@ -77,7 +77,8 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
sessionKey: sessionsControllerInternal.currentSessionKey,
persistDefaultSelection: true,
);
if (resolvedTarget == AssistantExecutionTarget.singleAgent) {
if (resolvedTarget == AssistantExecutionTarget.singleAgent ||
resolvedTarget == AssistantExecutionTarget.auto) {
await refreshSingleAgentSkillsForSession(
sessionsControllerInternal.currentSessionKey,
);
@ -165,7 +166,8 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
);
}
if (resolvedTarget == AssistantExecutionTarget.singleAgent) {
if (resolvedTarget == AssistantExecutionTarget.singleAgent ||
resolvedTarget == AssistantExecutionTarget.auto) {
if (runtimeInternal.isConnected) {
preserveGatewayHistoryForSessionInternal(normalizedSessionKey);
}

View File

@ -106,13 +106,20 @@ extension AppControllerWebGatewayChat on AppController {
);
return;
}
if (target == AssistantExecutionTarget.singleAgent) {
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
await executeGoAgentCoreRunInternal(
sessionKey: sessionKey,
prompt: trimmed,
target: target,
provider: singleAgentProviderForSession(sessionKey),
model: assistantModelForSession(sessionKey),
target: target == AssistantExecutionTarget.auto
? AssistantExecutionTarget.singleAgent
: target,
provider: target == AssistantExecutionTarget.auto
? SingleAgentProvider.auto
: singleAgentProviderForSession(sessionKey),
model: target == AssistantExecutionTarget.auto
? ''
: assistantModelForSession(sessionKey),
thinking: thinking,
attachments: attachments,
selectedSkillLabels: selectedSkillLabels,
@ -291,9 +298,11 @@ extension AppControllerWebGatewayChat on AppController {
}) {
final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey);
final thread = threadRecordsInternal[normalizedSessionKey];
final preferredGatewayTarget = switch (assistantExecutionTargetForSession(
final sessionTarget = assistantExecutionTargetForSession(
normalizedSessionKey,
)) {
);
final preferredGatewayTarget = switch (sessionTarget) {
AssistantExecutionTarget.auto => 'local',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
AssistantExecutionTarget.singleAgent => 'remote',
@ -318,6 +327,9 @@ extension AppControllerWebGatewayChat on AppController {
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
final resolvedExplicitExecutionTarget =
sessionTarget == AssistantExecutionTarget.auto
? ''
:
explicitExecutionTarget?.trim().isNotEmpty == true
? explicitExecutionTarget!.trim()
: (thread?.hasExplicitExecutionTargetSelection ?? false)
@ -326,11 +338,16 @@ extension AppControllerWebGatewayChat on AppController {
)
: '';
final resolvedExplicitProviderId =
sessionTarget == AssistantExecutionTarget.auto
? ''
:
thread?.hasExplicitProviderSelection ?? false
? singleAgentProviderForSession(normalizedSessionKey).providerId
: '';
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
? assistantModelForSession(normalizedSessionKey)
? (sessionTarget == AssistantExecutionTarget.auto
? ''
: assistantModelForSession(normalizedSessionKey))
: '';
final resolvedExplicitSkills = thread?.hasExplicitSkillSelection ?? false
? selectedSkills
@ -362,6 +379,7 @@ extension AppControllerWebGatewayChat on AppController {
String _webRoutingExecutionTargetValue(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.auto => 'singleAgent',
AssistantExecutionTarget.singleAgent => 'singleAgent',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',

View File

@ -128,6 +128,7 @@ extension AppControllerWebGatewayRelay on AppController {
existing?.executionBinding ??
ExecutionBinding(
executionMode: switch (resolvedExecutionTarget) {
AssistantExecutionTarget.auto => ThreadExecutionMode.auto,
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local =>

View File

@ -125,11 +125,12 @@ extension AppControllerWebHelpers on AppController {
AssistantExecutionTarget? target,
) {
return switch (target) {
AssistantExecutionTarget.auto => AssistantExecutionTarget.auto,
AssistantExecutionTarget.local => AssistantExecutionTarget.local,
AssistantExecutionTarget.remote => AssistantExecutionTarget.remote,
AssistantExecutionTarget.singleAgent =>
AssistantExecutionTarget.singleAgent,
_ => AssistantExecutionTarget.singleAgent,
_ => AssistantExecutionTarget.auto,
};
}
@ -139,6 +140,7 @@ extension AppControllerWebHelpers on AppController {
}) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final prefix = switch (target) {
AssistantExecutionTarget.auto => 'auto',
AssistantExecutionTarget.singleAgent => 'single',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
@ -164,6 +166,7 @@ extension AppControllerWebHelpers on AppController {
),
executionBinding: ExecutionBinding(
executionMode: switch (target) {
AssistantExecutionTarget.auto => ThreadExecutionMode.auto,
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal,
@ -249,6 +252,7 @@ extension AppControllerWebHelpers on AppController {
))
.copyWith(
executionMode: switch (executionTarget) {
AssistantExecutionTarget.auto => ThreadExecutionMode.auto,
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal,
@ -391,12 +395,13 @@ extension AppControllerWebHelpers on AppController {
return switch (mode) {
RuntimeConnectionMode.local => AssistantExecutionTarget.local,
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote,
RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.remote,
RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.auto,
};
}
int profileIndexForTargetInternal(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.auto => kGatewayLocalProfileIndex,
AssistantExecutionTarget.local => kGatewayLocalProfileIndex,
AssistantExecutionTarget.remote => kGatewayRemoteProfileIndex,
AssistantExecutionTarget.singleAgent => kGatewayRemoteProfileIndex,
@ -407,6 +412,8 @@ extension AppControllerWebHelpers on AppController {
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.auto =>
settingsInternal.primaryLocalGatewayProfile,
AssistantExecutionTarget.local =>
settingsInternal.primaryLocalGatewayProfile,
AssistantExecutionTarget.remote =>

View File

@ -34,7 +34,7 @@ extension AppControllerWebSessions on AppController {
final fallback = sanitizeTargetInternal(
settingsInternal.assistantExecutionTarget,
);
return recordTarget ?? fallback ?? AssistantExecutionTarget.singleAgent;
return recordTarget ?? fallback ?? AssistantExecutionTarget.auto;
}
AssistantExecutionTarget get assistantExecutionTarget =>
@ -42,7 +42,8 @@ extension AppControllerWebSessions on AppController {
AssistantExecutionTarget get currentAssistantExecutionTarget =>
assistantExecutionTarget;
bool get isSingleAgentMode =>
assistantExecutionTarget == AssistantExecutionTarget.singleAgent;
assistantExecutionTarget == AssistantExecutionTarget.singleAgent ||
assistantExecutionTarget == AssistantExecutionTarget.auto;
AssistantMessageViewMode assistantMessageViewModeForSession(
String sessionKey,
@ -122,10 +123,7 @@ extension AppControllerWebSessions on AppController {
singleAgentProviderForSession(currentSessionKeyInternal);
List<SingleAgentProvider> get singleAgentProviderOptions =>
<SingleAgentProvider>[
SingleAgentProvider.auto,
...settingsInternal.availableSingleAgentProviders,
];
settingsInternal.availableSingleAgentProviders;
bool singleAgentUsesAiChatFallbackForSession(String sessionKey) {
final provider = singleAgentProviderForSession(sessionKey);
@ -152,7 +150,8 @@ extension AppControllerWebSessions on AppController {
final recordModel =
threadRecordsInternal[normalizedSessionKey]?.assistantModelId.trim() ??
'';
if (target == AssistantExecutionTarget.singleAgent) {
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) {
if (recordModel.isNotEmpty) {
return recordModel;
@ -181,7 +180,8 @@ extension AppControllerWebSessions on AppController {
List<String> assistantModelChoicesForSession(String sessionKey) {
final target = assistantExecutionTargetForSession(sessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
return aiGatewayConversationModelChoices;
}
@ -441,7 +441,8 @@ extension AppControllerWebSessions on AppController {
) {
final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey);
final target = assistantExecutionTargetForSession(normalizedSessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
final provider = singleAgentProviderForSession(normalizedSessionKey);
final model = assistantModelForSession(normalizedSessionKey);
final host = hostLabelInternal(settingsInternal.aiGateway.baseUrl);
@ -452,9 +453,13 @@ extension AppControllerWebSessions on AppController {
status: canUseAiGatewayConversation
? RuntimeConnectionStatus.connected
: RuntimeConnectionStatus.offline,
primaryLabel: target.label,
primaryLabel: target == AssistantExecutionTarget.auto
? 'Auto'
: target.label,
detailLabel: detail.isEmpty
? appText('单机智能体未配置', 'Single Agent not configured')
: target == AssistantExecutionTarget.auto
? '${appText('当前: ', 'Current: ')}$detail'
: detail,
ready: canUseAiGatewayConversation,
pairingRequired: false,
@ -473,9 +478,11 @@ extension AppControllerWebSessions on AppController {
status: remoteReady
? RuntimeConnectionStatus.connected
: RuntimeConnectionStatus.offline,
primaryLabel: target.label,
primaryLabel: target == AssistantExecutionTarget.auto
? 'Auto'
: target.label,
detailLabel: remoteReady
? joinConnectionPartsInternal(<String>[provider.label, model])
? '${target == AssistantExecutionTarget.auto ? appText('当前: ', 'Current: ') : ''}${joinConnectionPartsInternal(<String>[provider.label, model])}'
: appText(
'${provider.label} 需要 Remote ACP${remoteAddress.isEmpty ? 'Remote Gateway' : remoteAddress}',
'${provider.label} requires Remote ACP (${remoteAddress.isEmpty ? 'Remote Gateway' : remoteAddress}).',

View File

@ -521,6 +521,9 @@ class UiFeatureAccess {
List<AssistantExecutionTarget> get availableExecutionTargets {
final targets = <AssistantExecutionTarget>[];
if (platform != UiFeaturePlatform.mobile) {
targets.add(AssistantExecutionTarget.auto);
}
if (supportsDirectAi) {
targets.add(AssistantExecutionTarget.singleAgent);
}
@ -542,10 +545,12 @@ class UiFeatureAccess {
}
final preferredOrder = platform == UiFeaturePlatform.web
? const <AssistantExecutionTarget>[
AssistantExecutionTarget.auto,
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.remote,
]
: const <AssistantExecutionTarget>[
AssistantExecutionTarget.auto,
AssistantExecutionTarget.local,
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.remote,
@ -556,8 +561,8 @@ class UiFeatureAccess {
}
}
return platform == UiFeaturePlatform.web
? AssistantExecutionTarget.singleAgent
: AssistantExecutionTarget.local;
? AssistantExecutionTarget.auto
: AssistantExecutionTarget.auto;
}
}

View File

@ -442,7 +442,8 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
),
),
const SizedBox(width: 4),
if (singleAgent) ...[
if (singleAgent &&
executionTarget != AssistantExecutionTarget.auto) ...[
PopupMenuButton<SingleAgentProvider>(
key: const Key('assistant-single-agent-provider-button'),
tooltip: appText('单机智能体执行器', 'Single Agent Provider'),

View File

@ -195,6 +195,7 @@ class ComposerToolbarChipStateInternal
extension AssistantExecutionTargetIconInternal on AssistantExecutionTarget {
IconData get icon => switch (this) {
AssistantExecutionTarget.auto => Icons.auto_awesome_rounded,
AssistantExecutionTarget.singleAgent => Icons.hub_outlined,
AssistantExecutionTarget.local => Icons.computer_outlined,
AssistantExecutionTarget.remote => Icons.cloud_outlined,

View File

@ -179,10 +179,18 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
inputController: inputControllerInternal,
focusNode: composerFocusNodeInternal,
thinkingLabel: thinkingLabelInternal,
showModelControl: !controller.isSingleAgentMode
showModelControl:
controller.currentAssistantExecutionTarget ==
AssistantExecutionTarget.auto
? false
: !controller.isSingleAgentMode
? true
: controller.currentSingleAgentShouldShowModelControl,
modelLabel: controller.isSingleAgentMode
modelLabel:
controller.currentAssistantExecutionTarget ==
AssistantExecutionTarget.auto
? 'Auto'
: controller.isSingleAgentMode
? controller.currentSingleAgentModelDisplayLabel
: controller.resolvedAssistantModel.isEmpty
? appText('未选择模型', 'No model selected')

View File

@ -147,6 +147,7 @@ class GoAgentCoreSessionRequest {
return 'multi-agent';
}
return switch (target) {
AssistantExecutionTarget.auto => 'single-agent',
AssistantExecutionTarget.singleAgent => 'single-agent',
AssistantExecutionTarget.local => 'gateway-chat',
AssistantExecutionTarget.remote => 'gateway-chat',

View File

@ -38,10 +38,11 @@ extension RuntimeConnectionStatusCopy on RuntimeConnectionStatus {
};
}
enum AssistantExecutionTarget { singleAgent, local, remote }
enum AssistantExecutionTarget { auto, singleAgent, local, remote }
extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
String get label => switch (this) {
AssistantExecutionTarget.auto => 'Auto',
AssistantExecutionTarget.singleAgent => appText('单机智能体', 'Single Agent'),
AssistantExecutionTarget.local => appText(
'本地 OpenClaw Gateway',
@ -54,6 +55,7 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
};
String get promptValue => switch (this) {
AssistantExecutionTarget.auto => 'auto',
AssistantExecutionTarget.singleAgent => 'single-agent',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
@ -62,6 +64,8 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
static AssistantExecutionTarget fromJsonValue(String? value) {
final normalized = value?.trim() ?? '';
switch (normalized) {
case 'auto':
return AssistantExecutionTarget.auto;
case 'singleAgent':
case 'aiGatewayOnly':
case 'single-agent':
@ -72,7 +76,7 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
case 'remote':
return AssistantExecutionTarget.remote;
default:
return AssistantExecutionTarget.local;
return AssistantExecutionTarget.auto;
}
}
}

View File

@ -319,7 +319,8 @@ class AssistantThreadConnectionState {
final String? lastError;
bool get isSingleAgent =>
executionTarget == AssistantExecutionTarget.singleAgent;
executionTarget == AssistantExecutionTarget.singleAgent ||
executionTarget == AssistantExecutionTarget.auto;
bool get connected => ready;

View File

@ -488,12 +488,14 @@ extension WorkspaceKindCopy on WorkspaceKind {
}
}
enum ThreadExecutionMode { localAgent, gatewayLocal, gatewayRemote }
enum ThreadExecutionMode { auto, localAgent, gatewayLocal, gatewayRemote }
extension ThreadExecutionModeCopy on ThreadExecutionMode {
static ThreadExecutionMode fromJsonValue(String? value) {
final normalized = value?.trim();
switch (normalized) {
case 'auto':
return ThreadExecutionMode.auto;
case 'singleAgent':
case 'local_agent':
case 'localAgent':
@ -507,7 +509,7 @@ extension ThreadExecutionModeCopy on ThreadExecutionMode {
case 'gatewayRemote':
return ThreadExecutionMode.gatewayRemote;
default:
return ThreadExecutionMode.localAgent;
return ThreadExecutionMode.auto;
}
}
}
@ -1006,6 +1008,7 @@ class TaskThread {
SingleAgentProviderCopy.fromJsonValue(executionBinding.providerId);
AssistantExecutionTarget get executionTarget =>
switch (executionBinding.executionMode) {
ThreadExecutionMode.auto => AssistantExecutionTarget.auto,
ThreadExecutionMode.localAgent => AssistantExecutionTarget.singleAgent,
ThreadExecutionMode.gatewayLocal => AssistantExecutionTarget.local,
ThreadExecutionMode.gatewayRemote => AssistantExecutionTarget.remote,
@ -1052,6 +1055,7 @@ class TaskThread {
: executionTarget == null
? nextExecutionBinding.executionMode
: switch (executionTarget) {
AssistantExecutionTarget.auto => ThreadExecutionMode.auto,
AssistantExecutionTarget.singleAgent =>
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal,
@ -1115,7 +1119,8 @@ class TaskThread {
static ThreadExecutionMode _executionModeFromLegacy(
AssistantExecutionTarget? target,
) {
return switch (target ?? AssistantExecutionTarget.singleAgent) {
return switch (target ?? AssistantExecutionTarget.auto) {
AssistantExecutionTarget.auto => ThreadExecutionMode.auto,
AssistantExecutionTarget.singleAgent => ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal,
AssistantExecutionTarget.remote => ThreadExecutionMode.gatewayRemote,

View File

@ -116,7 +116,7 @@ class SettingsSnapshot {
accountWorkspaceFollowed: false,
accountLocalMode: true,
linuxDesktop: LinuxDesktopConfig.defaults(),
assistantExecutionTarget: AssistantExecutionTarget.local,
assistantExecutionTarget: AssistantExecutionTarget.auto,
assistantPermissionLevel: AssistantPermissionLevel.defaultAccess,
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
assistantCustomTaskTitles: const <String, String>{},
@ -451,6 +451,7 @@ class SettingsSnapshot {
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.auto => primaryLocalGatewayProfile,
AssistantExecutionTarget.singleAgent => null,
AssistantExecutionTarget.local => primaryLocalGatewayProfile,
AssistantExecutionTarget.remote => primaryRemoteGatewayProfile,
@ -471,6 +472,7 @@ class SettingsSnapshot {
GatewayConnectionProfile profile,
) {
final index = switch (target) {
AssistantExecutionTarget.auto => kGatewayLocalProfileIndex,
AssistantExecutionTarget.local => kGatewayLocalProfileIndex,
AssistantExecutionTarget.remote => kGatewayRemoteProfileIndex,
AssistantExecutionTarget.singleAgent => null,

View File

@ -264,6 +264,7 @@ String thinkingLabelInternal(String level) {
String targetLabelInternal(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.auto => 'Auto',
AssistantExecutionTarget.singleAgent => appText('单机智能体', 'Single Agent'),
AssistantExecutionTarget.local => appText(
'本地 OpenClaw Gateway',

View File

@ -38,6 +38,7 @@ String themeLabelInternal(ThemeMode mode) {
String targetLabelInternal(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.auto => 'Auto',
AssistantExecutionTarget.singleAgent => appText(
'Single Agent',
'Single Agent',

View File

@ -562,6 +562,7 @@ String _sidebarTaskUpdatedAtLabel(double? updatedAtMs) {
IconData _sidebarTaskTargetIcon(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.auto => Icons.auto_awesome_rounded,
AssistantExecutionTarget.singleAgent => Icons.hub_outlined,
AssistantExecutionTarget.local => Icons.computer_outlined,
AssistantExecutionTarget.remote => Icons.cloud_outlined,

View File

@ -75,6 +75,7 @@ void main() {
expect(
desktopAccess.availableExecutionTargets,
equals(<AssistantExecutionTarget>[
AssistantExecutionTarget.auto,
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
@ -87,6 +88,7 @@ void main() {
expect(
webAccess.availableExecutionTargets,
equals(<AssistantExecutionTarget>[
AssistantExecutionTarget.auto,
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.local,
AssistantExecutionTarget.remote,
@ -94,6 +96,27 @@ void main() {
);
});
test('sanitizeExecutionTarget prefers auto when available', () {
final manifest = UiFeatureManifest.fallback();
final desktopAccess = manifest.forPlatform(
UiFeaturePlatform.desktop,
buildMode: UiFeatureBuildMode.release,
);
final webAccess = manifest.forPlatform(
UiFeaturePlatform.web,
buildMode: UiFeatureBuildMode.release,
);
expect(
desktopAccess.sanitizeExecutionTarget(null),
AssistantExecutionTarget.auto,
);
expect(
webAccess.sanitizeExecutionTarget(null),
AssistantExecutionTarget.auto,
);
});
test('parser rejects unsupported flag fields', () {
expect(
() => UiFeatureManifest.fromYamlString('''

View File

@ -141,7 +141,7 @@ void registerAssistantPageSuiteComposerTestsInternal() {
find.byKey(const Key('assistant-permission-button')),
findsOneWidget,
);
expect(find.byKey(const Key('assistant-model-button')), findsOneWidget);
expect(find.byKey(const Key('assistant-model-button')), findsNothing);
expect(find.byKey(const Key('assistant-thinking-button')), findsOneWidget);
expect(find.byTooltip('模式'), findsNothing);
@ -161,6 +161,7 @@ void registerAssistantPageSuiteComposerTestsInternal() {
);
await pumpForUiSyncInternal(tester);
expect(find.text('Auto'), findsWidgets);
expect(find.text('单机智能体'), findsWidgets);
expect(find.text('本地 OpenClaw Gateway'), findsWidgets);
expect(find.text('远程 OpenClaw Gateway'), findsWidgets);

View File

@ -218,6 +218,9 @@ void registerAssistantPageSuiteCoreTestsInternal() {
final aiGroup = find.byKey(
const ValueKey<String>('assistant-task-group-singleAgent'),
);
final autoGroup = find.byKey(
const ValueKey<String>('assistant-task-group-auto'),
);
final localGroup = find.byKey(
const ValueKey<String>('assistant-task-group-local'),
);
@ -225,10 +228,15 @@ void registerAssistantPageSuiteCoreTestsInternal() {
const ValueKey<String>('assistant-task-group-remote'),
);
expect(autoGroup, findsOneWidget);
expect(aiGroup, findsOneWidget);
expect(localGroup, findsOneWidget);
expect(remoteGroup, findsOneWidget);
expect(
tester.getTopLeft(autoGroup).dy,
lessThan(tester.getTopLeft(aiGroup).dy),
);
expect(
tester.getTopLeft(aiGroup).dy,
lessThan(tester.getTopLeft(localGroup).dy),
@ -322,20 +330,9 @@ void registerAssistantPageSuiteCoreTestsInternal() {
);
final toggle = find.byKey(const Key('assistant-artifact-pane-toggle'));
final decoratedBody = find.descendant(
of: toggle,
matching: find.byWidgetPredicate(
(widget) => widget is Container && widget.decoration is BoxDecoration,
),
);
expect(toggle, findsOneWidget);
expect(tester.getSize(toggle), const Size(32, 36));
final body = tester.widget<Container>(decoratedBody);
final decoration = body.decoration! as BoxDecoration;
expect(decoration.borderRadius, BorderRadius.circular(8));
expect(tester.getSize(toggle), const Size(20, 20));
});
testWidgets(
@ -344,7 +341,7 @@ void registerAssistantPageSuiteCoreTestsInternal() {
skip: true,
);
testWidgets('AssistantPage shows three collapsed task groups by default', (
testWidgets('AssistantPage shows four collapsed task groups by default', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
@ -354,6 +351,10 @@ void registerAssistantPageSuiteCoreTestsInternal() {
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-auto')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey<String>('assistant-task-group-singleAgent')),
findsOneWidget,
@ -372,7 +373,7 @@ void registerAssistantPageSuiteCoreTestsInternal() {
);
await tester.tap(
find.byKey(const ValueKey<String>('assistant-task-group-local')),
find.byKey(const ValueKey<String>('assistant-task-group-auto')),
);
await pumpForUiSyncInternal(tester);
@ -464,6 +465,7 @@ void registerAssistantPageSuiteCoreTestsInternal() {
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await controller.setAssistantExecutionTarget(AssistantExecutionTarget.local);
await pumpPage(
tester,

View File

@ -17,7 +17,7 @@ import 'app_controller_execution_target_switch_suite_thread.dart';
import 'app_controller_execution_target_switch_suite_fakes.dart';
Future<void> waitForInternal(bool Function() predicate) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
final deadline = DateTime.now().add(const Duration(seconds: 10));
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
fail('condition not met before timeout');

View File

@ -378,7 +378,7 @@ void registerExecutionTargetSwitchThreadTests() {
controller.settings.accountUsername,
SettingsSnapshot.defaults().accountUsername,
);
expect(controller.settings.assistantLastSessionKey, isEmpty);
expect(controller.settings.assistantLastSessionKey, 'main');
expect(controller.assistantCustomTaskTitle('draft:clear-me'), isEmpty);
final reloadedStore = SecureConfigStore(
@ -394,8 +394,13 @@ void registerExecutionTargetSwitchThreadTests() {
reloadedSnapshot.accountUsername,
SettingsSnapshot.defaults().accountUsername,
);
expect(reloadedSnapshot.assistantLastSessionKey, isEmpty);
expect(reloadedThreads, isEmpty);
expect(reloadedSnapshot.assistantLastSessionKey, 'main');
expect(reloadedThreads, hasLength(1));
expect(reloadedThreads.single.sessionKey, 'main');
expect(
reloadedThreads.single.executionTarget,
AssistantExecutionTarget.auto,
);
},
);
});

View File

@ -52,6 +52,7 @@ TaskThread buildTaskThreadFixture({
),
executionBinding: ExecutionBinding(
executionMode: switch (executionTarget) {
AssistantExecutionTarget.auto => ThreadExecutionMode.auto,
AssistantExecutionTarget.singleAgent => ThreadExecutionMode.localAgent,
AssistantExecutionTarget.local => ThreadExecutionMode.gatewayLocal,
AssistantExecutionTarget.remote => ThreadExecutionMode.gatewayRemote,