Refine assistant provider settings flows
This commit is contained in:
parent
508d6cc330
commit
153db679f5
@ -959,7 +959,8 @@ class AppController extends ChangeNotifier {
|
||||
threadId: sessionKey,
|
||||
prompt: composedPrompt,
|
||||
workingDirectory:
|
||||
_resolveCodexWorkingDirectory() ?? Directory.current.path,
|
||||
_assistantWorkingDirectoryForSession(sessionKey) ??
|
||||
Directory.current.path,
|
||||
attachments: attachments,
|
||||
selectedSkills: selectedSkillLabels,
|
||||
aiGatewayBaseUrl: aiGatewayUrl,
|
||||
@ -1301,8 +1302,14 @@ class AppController extends ChangeNotifier {
|
||||
resolvedTarget,
|
||||
);
|
||||
final existing = _assistantThreadRecords[normalizedSessionKey];
|
||||
final existingWorkspaceRef = existing?.workspaceRef.trim() ?? '';
|
||||
if (existing != null &&
|
||||
existing.workspaceRef == nextWorkspaceRef &&
|
||||
existingWorkspaceRef.isNotEmpty &&
|
||||
existing.workspaceRefKind == nextWorkspaceRefKind) {
|
||||
return;
|
||||
}
|
||||
if (existing != null &&
|
||||
existingWorkspaceRef == nextWorkspaceRef &&
|
||||
existing.workspaceRefKind == nextWorkspaceRefKind) {
|
||||
return;
|
||||
}
|
||||
@ -1880,8 +1887,10 @@ class AppController extends ChangeNotifier {
|
||||
executionTarget: nextTarget,
|
||||
messageViewMode: nextViewMode,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
workspaceRef: _defaultWorkspaceRefForSession(nextSessionKey),
|
||||
workspaceRefKind: _defaultWorkspaceRefKindForTarget(nextTarget),
|
||||
);
|
||||
_syncAssistantWorkspaceRefForSession(
|
||||
nextSessionKey,
|
||||
executionTarget: nextTarget,
|
||||
);
|
||||
await _applyAssistantExecutionTarget(
|
||||
nextTarget,
|
||||
@ -1981,12 +1990,10 @@ class AppController extends ChangeNotifier {
|
||||
_sessionsController.currentSessionKey,
|
||||
executionTarget: resolvedTarget,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
workspaceRef: switch (resolvedTarget) {
|
||||
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
|
||||
AssistantExecutionTarget.local ||
|
||||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
|
||||
},
|
||||
workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget),
|
||||
);
|
||||
_syncAssistantWorkspaceRefForSession(
|
||||
_sessionsController.currentSessionKey,
|
||||
executionTarget: resolvedTarget,
|
||||
);
|
||||
_recomputeTasks();
|
||||
_notifyIfActive();
|
||||
@ -3588,7 +3595,8 @@ class AppController extends ChangeNotifier {
|
||||
model: assistantModelForSession(sessionKey),
|
||||
gatewayToken: gatewayToken,
|
||||
workingDirectory:
|
||||
_resolveCodexWorkingDirectory() ?? Directory.current.path,
|
||||
_resolveLocalAssistantWorkingDirectoryForSession(sessionKey) ??
|
||||
Directory.current.path,
|
||||
attachments: localAttachments,
|
||||
selectedSkills: selectedSkills,
|
||||
aiGatewayBaseUrl: aiGatewayUrl,
|
||||
@ -5299,11 +5307,23 @@ class AppController extends ChangeNotifier {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String? _resolveCodexWorkingDirectory() {
|
||||
final candidate = settings.workspacePath.trim();
|
||||
String? _assistantWorkingDirectoryForSession(String sessionKey) {
|
||||
final candidate = assistantWorkspaceRefForSession(sessionKey).trim();
|
||||
if (candidate.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
String? _resolveLocalAssistantWorkingDirectoryForSession(String sessionKey) {
|
||||
if (assistantWorkspaceRefKindForSession(sessionKey) !=
|
||||
WorkspaceRefKind.localPath) {
|
||||
return null;
|
||||
}
|
||||
final candidate = _assistantWorkingDirectoryForSession(sessionKey);
|
||||
if (candidate == null) {
|
||||
return null;
|
||||
}
|
||||
final directory = Directory(candidate);
|
||||
return directory.existsSync() ? directory.path : null;
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ const double _assistantVerticalResizeHandleHeight = 10;
|
||||
const double _assistantArtifactPaneMinWidth = 280;
|
||||
const double _assistantArtifactPaneDefaultWidth = 360;
|
||||
const double _assistantCollapsedArtifactToggleClearance = 56;
|
||||
const double _assistantComposerSafeAreaGap = 8;
|
||||
|
||||
typedef AssistantClipboardImageReader = Future<XFile?> Function();
|
||||
|
||||
@ -396,6 +397,14 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
}) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final composerBottomInset = math.max(
|
||||
mediaQuery.viewPadding.bottom,
|
||||
mediaQuery.viewInsets.bottom,
|
||||
);
|
||||
final composerBottomSpacing = composerBottomInset > 0
|
||||
? composerBottomInset + _assistantComposerSafeAreaGap
|
||||
: _assistantComposerSafeAreaGap;
|
||||
final baseComposerHeight = constraints.maxHeight >= 900 ? 180.0 : 152.0;
|
||||
final composerContentWidth = math.max(240.0, constraints.maxWidth - 32);
|
||||
final availableWorkspaceHeight = math.max(
|
||||
@ -420,17 +429,18 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
_composerInputHeight - _assistantComposerDefaultInputHeight,
|
||||
) +
|
||||
attachmentExtraHeight +
|
||||
selectedSkillExtraHeight,
|
||||
selectedSkillExtraHeight +
|
||||
composerBottomSpacing,
|
||||
);
|
||||
final composerHeightUpperBound = math.min(
|
||||
availableWorkspaceHeight,
|
||||
math.max(
|
||||
_assistantWorkspaceMinLowerPaneHeight,
|
||||
_assistantWorkspaceMinLowerPaneHeight + composerBottomSpacing,
|
||||
availableWorkspaceHeight - _assistantWorkspaceMinConversationHeight,
|
||||
),
|
||||
);
|
||||
final composerHeightLowerBound = math.min(
|
||||
_assistantWorkspaceMinLowerPaneHeight,
|
||||
_assistantWorkspaceMinLowerPaneHeight + composerBottomSpacing,
|
||||
composerHeightUpperBound,
|
||||
);
|
||||
final composerHeight =
|
||||
@ -448,6 +458,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
currentTask: currentTask,
|
||||
items: timelineItems,
|
||||
messageViewMode: controller.currentAssistantMessageViewMode,
|
||||
bottomContentInset: composerBottomSpacing,
|
||||
topTrailingInset: _artifactPaneCollapsed
|
||||
? _assistantCollapsedArtifactToggleClearance
|
||||
: 0,
|
||||
@ -485,6 +496,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
key: const Key('assistant-composer-shell'),
|
||||
height: composerHeight,
|
||||
child: _AssistantLowerPane(
|
||||
bottomContentInset: composerBottomSpacing,
|
||||
inputController: _inputController,
|
||||
focusNode: _composerFocusNode,
|
||||
thinkingLabel: _thinkingLabel,
|
||||
@ -790,7 +802,11 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
await controller.selectAgent(autoAgent.id);
|
||||
}
|
||||
|
||||
final attachmentNames = _attachments
|
||||
final submittedAttachments = List<_ComposerAttachment>.from(
|
||||
_attachments,
|
||||
growable: false,
|
||||
);
|
||||
final attachmentNames = submittedAttachments
|
||||
.map((item) => item.name)
|
||||
.toList(growable: false);
|
||||
final selectedSkillLabels = _resolveSelectedSkillLabels(controller);
|
||||
@ -813,6 +829,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
_lastAutoAgentLabel =
|
||||
autoAgent?.name ?? _conversationOwnerLabel(controller);
|
||||
_lastSubmittedAttachments = attachmentNames;
|
||||
_attachments = const <_ComposerAttachment>[];
|
||||
_touchTaskSeed(
|
||||
sessionKey: controller.currentSessionKey,
|
||||
title:
|
||||
@ -831,31 +848,12 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
draft: controller.currentSessionKey.trim().startsWith('draft:'),
|
||||
);
|
||||
});
|
||||
_inputController.clear();
|
||||
|
||||
if (uiFeatures.supportsMultiAgent &&
|
||||
controller.settings.multiAgent.enabled) {
|
||||
final collaborationAttachments = _attachments
|
||||
.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 _buildAttachmentPayloads(_attachments);
|
||||
await controller.sendChatMessage(
|
||||
prompt,
|
||||
thinking: _thinkingLabel,
|
||||
attachments: attachmentPayloads,
|
||||
localAttachments: _attachments
|
||||
try {
|
||||
if (uiFeatures.supportsMultiAgent &&
|
||||
controller.settings.multiAgent.enabled) {
|
||||
final collaborationAttachments = submittedAttachments
|
||||
.map(
|
||||
(item) => CollaborationAttachment(
|
||||
name: item.name,
|
||||
@ -863,18 +861,50 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
path: item.path,
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
selectedSkillLabels: selectedSkillLabels,
|
||||
);
|
||||
.toList(growable: false);
|
||||
await controller.runMultiAgentCollaboration(
|
||||
rawPrompt: rawPrompt,
|
||||
composedPrompt: prompt,
|
||||
attachments: collaborationAttachments,
|
||||
selectedSkillLabels: selectedSkillLabels,
|
||||
);
|
||||
} else {
|
||||
final attachmentPayloads = await _buildAttachmentPayloads(
|
||||
submittedAttachments,
|
||||
);
|
||||
await controller.sendChatMessage(
|
||||
prompt,
|
||||
thinking: _thinkingLabel,
|
||||
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 (_inputController.text.trim().isEmpty) {
|
||||
_inputController.value = TextEditingValue(
|
||||
text: rawPrompt,
|
||||
selection: TextSelection.collapsed(offset: rawPrompt.length),
|
||||
);
|
||||
}
|
||||
if (_attachments.isEmpty && submittedAttachments.isNotEmpty) {
|
||||
setState(() {
|
||||
_attachments = submittedAttachments;
|
||||
});
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_attachments = const <_ComposerAttachment>[];
|
||||
});
|
||||
_inputController.clear();
|
||||
}
|
||||
|
||||
Future<List<GatewayChatAttachmentPayload>> _buildAttachmentPayloads(
|
||||
@ -1736,6 +1766,7 @@ class _AssistantSideTabButtonState extends State<_AssistantSideTabButton> {
|
||||
|
||||
class _AssistantLowerPane extends StatelessWidget {
|
||||
const _AssistantLowerPane({
|
||||
required this.bottomContentInset,
|
||||
required this.controller,
|
||||
required this.inputController,
|
||||
required this.focusNode,
|
||||
@ -1760,6 +1791,7 @@ class _AssistantLowerPane extends StatelessWidget {
|
||||
required this.onSend,
|
||||
});
|
||||
|
||||
final double bottomContentInset;
|
||||
final AppController controller;
|
||||
final TextEditingController inputController;
|
||||
final FocusNode focusNode;
|
||||
@ -1789,29 +1821,32 @@ class _AssistantLowerPane extends StatelessWidget {
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: _ComposerBar(
|
||||
controller: controller,
|
||||
inputController: inputController,
|
||||
focusNode: focusNode,
|
||||
thinkingLabel: thinkingLabel,
|
||||
showModelControl: showModelControl,
|
||||
modelLabel: modelLabel,
|
||||
modelOptions: modelOptions,
|
||||
attachments: attachments,
|
||||
availableSkills: availableSkills,
|
||||
selectedSkillKeys: selectedSkillKeys,
|
||||
onRemoveAttachment: onRemoveAttachment,
|
||||
onToggleSkill: onToggleSkill,
|
||||
onThinkingChanged: onThinkingChanged,
|
||||
onModelChanged: onModelChanged,
|
||||
onOpenGateway: onOpenGateway,
|
||||
onOpenAiGatewaySettings: onOpenAiGatewaySettings,
|
||||
onReconnectGateway: onReconnectGateway,
|
||||
onPickAttachments: onPickAttachments,
|
||||
onAddAttachment: onAddAttachment,
|
||||
onPasteImageAttachment: onPasteImageAttachment,
|
||||
onInputHeightChanged: onComposerInputHeightChanged,
|
||||
onSend: onSend,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomContentInset),
|
||||
child: _ComposerBar(
|
||||
controller: controller,
|
||||
inputController: inputController,
|
||||
focusNode: focusNode,
|
||||
thinkingLabel: thinkingLabel,
|
||||
showModelControl: showModelControl,
|
||||
modelLabel: modelLabel,
|
||||
modelOptions: modelOptions,
|
||||
attachments: attachments,
|
||||
availableSkills: availableSkills,
|
||||
selectedSkillKeys: selectedSkillKeys,
|
||||
onRemoveAttachment: onRemoveAttachment,
|
||||
onToggleSkill: onToggleSkill,
|
||||
onThinkingChanged: onThinkingChanged,
|
||||
onModelChanged: onModelChanged,
|
||||
onOpenGateway: onOpenGateway,
|
||||
onOpenAiGatewaySettings: onOpenAiGatewaySettings,
|
||||
onReconnectGateway: onReconnectGateway,
|
||||
onPickAttachments: onPickAttachments,
|
||||
onAddAttachment: onAddAttachment,
|
||||
onPasteImageAttachment: onPasteImageAttachment,
|
||||
onInputHeightChanged: onComposerInputHeightChanged,
|
||||
onSend: onSend,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -1824,6 +1859,7 @@ class _ConversationArea extends StatelessWidget {
|
||||
required this.currentTask,
|
||||
required this.items,
|
||||
required this.messageViewMode,
|
||||
required this.bottomContentInset,
|
||||
required this.topTrailingInset,
|
||||
required this.scrollController,
|
||||
required this.onOpenDetail,
|
||||
@ -1838,6 +1874,7 @@ class _ConversationArea extends StatelessWidget {
|
||||
final _AssistantTaskEntry currentTask;
|
||||
final List<_TimelineItem> items;
|
||||
final AssistantMessageViewMode messageViewMode;
|
||||
final double bottomContentInset;
|
||||
final double topTrailingInset;
|
||||
final ScrollController scrollController;
|
||||
final ValueChanged<DetailPanelData> onOpenDetail;
|
||||
@ -1892,7 +1929,12 @@ class _ConversationArea extends StatelessWidget {
|
||||
)
|
||||
: ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
10,
|
||||
8,
|
||||
10,
|
||||
8 + bottomContentInset,
|
||||
),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 6),
|
||||
|
||||
@ -914,8 +914,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'这里仅保留 Codex、OpenCode 预设接入。历史上的 Claude / Gemini 预配置会迁移为自定义 ACP Server Endpoint。你可以继续添加多个自定义 Endpoint,协议支持 ws / wss / http / https。',
|
||||
'Only Codex and OpenCode stay as preset integrations here. Legacy Claude and Gemini entries are migrated into custom ACP server endpoints. You can add multiple custom endpoints with ws / wss / http / https.',
|
||||
'这里保留 Codex、OpenCode 作为内建接入。更多 Provider 请通过向导新增自定义 ACP Server Endpoint;历史上真正配置过的 Claude / Gemini 会迁移为自定义条目,空白旧预设会自动清理。',
|
||||
'Codex and OpenCode stay here as built-in integrations. Add more providers through the custom ACP endpoint wizard; configured legacy Claude and Gemini entries are migrated into custom entries, while empty legacy presets are cleaned up automatically.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
@ -924,16 +924,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.tonalIcon(
|
||||
key: const ValueKey('external-acp-provider-add-button'),
|
||||
onPressed: () => _saveSettings(
|
||||
onPressed: () => _showAddExternalAcpProviderWizard(
|
||||
context,
|
||||
controller,
|
||||
_appendExternalAcpProvider(settings),
|
||||
settings,
|
||||
),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(
|
||||
appText(
|
||||
'添加自定义 ACP Server Endpoint',
|
||||
'Add custom ACP server endpoint',
|
||||
),
|
||||
appText('添加更多自定义配置', 'Add more custom configurations'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1040,30 +1038,147 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
SettingsSnapshot _appendExternalAcpProvider(SettingsSnapshot settings) {
|
||||
var suffix = settings.externalAcpEndpoints.length + 1;
|
||||
String providerKey() => 'custom-agent-$suffix';
|
||||
final existingKeys = settings.externalAcpEndpoints
|
||||
.map((item) => item.providerKey)
|
||||
.toSet();
|
||||
while (existingKeys.contains(providerKey())) {
|
||||
suffix += 1;
|
||||
}
|
||||
return settings.copyWith(
|
||||
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
|
||||
...settings.externalAcpEndpoints,
|
||||
ExternalAcpEndpointProfile(
|
||||
providerKey: providerKey(),
|
||||
label: appText(
|
||||
'自定义 ACP Endpoint $suffix',
|
||||
'Custom ACP Endpoint $suffix',
|
||||
),
|
||||
badge: '',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
Future<void> _showAddExternalAcpProviderWizard(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
) async {
|
||||
final nameController = TextEditingController();
|
||||
final endpointController = TextEditingController();
|
||||
var attemptedSubmit = false;
|
||||
try {
|
||||
final profile = await showDialog<ExternalAcpEndpointProfile>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final name = nameController.text.trim();
|
||||
final endpoint = endpointController.text.trim();
|
||||
final endpointValid =
|
||||
endpoint.isEmpty || isSupportedExternalAcpEndpoint(endpoint);
|
||||
final canSubmit =
|
||||
name.isNotEmpty && endpoint.isNotEmpty && endpointValid;
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
appText('添加自定义 ACP Endpoint', 'Add custom ACP endpoint'),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText(
|
||||
'通过向导新增更多外部 Agent Provider。先填写显示名称,再输入可访问的 ACP Server Endpoint。',
|
||||
'Use this wizard to add more external agent providers. Start with a display name, then enter a reachable ACP server endpoint.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
appText('步骤 1 · 显示名称', 'Step 1 · Display name'),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const ValueKey('external-acp-wizard-name-field'),
|
||||
controller: nameController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: appText(
|
||||
'例如:Claude Sonnet / Lab Agent',
|
||||
'For example: Claude Sonnet / Lab Agent',
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setDialogState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
appText(
|
||||
'步骤 2 · ACP Server Endpoint',
|
||||
'Step 2 · ACP Server Endpoint',
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const ValueKey(
|
||||
'external-acp-wizard-endpoint-field',
|
||||
),
|
||||
controller: endpointController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'ws://127.0.0.1:9001',
|
||||
errorText: attemptedSubmit && endpoint.isEmpty
|
||||
? appText(
|
||||
'请输入 ACP Server Endpoint。',
|
||||
'Enter an ACP server endpoint.',
|
||||
)
|
||||
: attemptedSubmit && !endpointValid
|
||||
? appText(
|
||||
'仅支持 ws / wss / http / https。',
|
||||
'Only ws / wss / http / https are supported.',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (_) => setDialogState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'支持协议:ws、wss、http、https。新增后会出现在下方列表,并和助手页的 provider 菜单保持一致。',
|
||||
'Supported schemes: ws, wss, http, https. The new entry appears in the list below and stays aligned with the assistant provider menu.',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(appText('取消', 'Cancel')),
|
||||
),
|
||||
FilledButton(
|
||||
key: const ValueKey('external-acp-wizard-confirm-button'),
|
||||
onPressed: canSubmit
|
||||
? () {
|
||||
Navigator.of(dialogContext).pop(
|
||||
buildCustomExternalAcpEndpointProfile(
|
||||
settings.externalAcpEndpoints,
|
||||
label: name,
|
||||
endpoint: endpoint,
|
||||
),
|
||||
);
|
||||
}
|
||||
: () {
|
||||
setDialogState(() {
|
||||
attemptedSubmit = true;
|
||||
});
|
||||
},
|
||||
child: Text(appText('添加', 'Add')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (profile == null) {
|
||||
return;
|
||||
}
|
||||
await _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(
|
||||
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
|
||||
...settings.externalAcpEndpoints,
|
||||
profile,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
} finally {
|
||||
nameController.dispose();
|
||||
endpointController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLlmEndpointManager(
|
||||
|
||||
@ -134,6 +134,23 @@ String _singleAgentProviderFallbackBadge({
|
||||
return stripped.substring(0, length).toUpperCase();
|
||||
}
|
||||
|
||||
const Set<String> kSupportedExternalAcpEndpointSchemes = <String>{
|
||||
'ws',
|
||||
'wss',
|
||||
'http',
|
||||
'https',
|
||||
};
|
||||
|
||||
bool isSupportedExternalAcpEndpoint(String endpoint) {
|
||||
final trimmed = endpoint.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
|
||||
return kSupportedExternalAcpEndpointSchemes.contains(scheme);
|
||||
}
|
||||
|
||||
class SingleAgentProvider {
|
||||
const SingleAgentProvider({
|
||||
required this.providerId,
|
||||
@ -391,6 +408,9 @@ List<ExternalAcpEndpointProfile> normalizeExternalAcpEndpoints({
|
||||
continue;
|
||||
}
|
||||
if (kLegacyExternalAcpProviderIds.contains(key)) {
|
||||
if (item.endpoint.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
migratedCustomProfiles.add(item.copyWith(providerKey: nextCustomKey()));
|
||||
continue;
|
||||
}
|
||||
@ -430,6 +450,38 @@ List<ExternalAcpEndpointProfile> replaceExternalAcpEndpointForProvider(
|
||||
return normalizeExternalAcpEndpoints(profiles: next);
|
||||
}
|
||||
|
||||
ExternalAcpEndpointProfile buildCustomExternalAcpEndpointProfile(
|
||||
Iterable<ExternalAcpEndpointProfile> profiles, {
|
||||
required String label,
|
||||
required String endpoint,
|
||||
}) {
|
||||
final normalizedProfiles = normalizeExternalAcpEndpoints(profiles: profiles);
|
||||
var suffix = normalizedProfiles.length + 1;
|
||||
|
||||
String providerKey() => 'custom-agent-$suffix';
|
||||
|
||||
final existingKeys = normalizedProfiles
|
||||
.map((item) => item.providerKey)
|
||||
.toSet();
|
||||
while (existingKeys.contains(providerKey())) {
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
final normalizedLabel = label.trim().isEmpty
|
||||
? 'Custom ACP Endpoint $suffix'
|
||||
: label.trim();
|
||||
return ExternalAcpEndpointProfile(
|
||||
providerKey: providerKey(),
|
||||
label: normalizedLabel,
|
||||
badge: _singleAgentProviderFallbackBadge(
|
||||
providerId: providerKey(),
|
||||
label: normalizedLabel,
|
||||
),
|
||||
endpoint: endpoint.trim(),
|
||||
enabled: true,
|
||||
);
|
||||
}
|
||||
|
||||
String normalizeAuthorizedSkillDirectoryPath(String path) {
|
||||
var trimmed = path.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
|
||||
@ -926,8 +926,8 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'这里仅保留 Codex、OpenCode 预设接入。历史上的 Claude / Gemini 预配置会迁移为自定义 ACP Server Endpoint。你可以继续添加多个自定义 Endpoint,协议支持 ws / wss / http / https。',
|
||||
'Only Codex and OpenCode stay as preset integrations here. Legacy Claude and Gemini entries are migrated into custom ACP server endpoints. You can add multiple custom endpoints with ws / wss / http / https.',
|
||||
'这里保留 Codex、OpenCode 作为内建接入。更多 Provider 请通过向导新增自定义 ACP Server Endpoint;历史上真正配置过的 Claude / Gemini 会迁移为自定义条目,空白旧预设会自动清理。',
|
||||
'Codex and OpenCode stay here as built-in integrations. Add more providers through the custom ACP endpoint wizard; configured legacy Claude and Gemini entries are migrated into custom entries, while empty legacy presets are cleaned up automatically.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
@ -936,19 +936,11 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.tonalIcon(
|
||||
key: const ValueKey('web-external-acp-provider-add-button'),
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
controller.saveSettingsDraft(
|
||||
_appendExternalAcpProvider(controller.settingsDraft),
|
||||
),
|
||||
);
|
||||
},
|
||||
onPressed: () =>
|
||||
_showAddExternalAcpProviderWizard(context, controller),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(
|
||||
appText(
|
||||
'添加自定义 ACP Server Endpoint',
|
||||
'Add custom ACP server endpoint',
|
||||
),
|
||||
appText('添加更多自定义配置', 'Add more custom configurations'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1083,30 +1075,150 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
unawaited(controller.saveSettingsDraft(next));
|
||||
}
|
||||
|
||||
SettingsSnapshot _appendExternalAcpProvider(SettingsSnapshot settings) {
|
||||
var suffix = settings.externalAcpEndpoints.length + 1;
|
||||
String providerKey() => 'custom-agent-$suffix';
|
||||
final existingKeys = settings.externalAcpEndpoints
|
||||
.map((item) => item.providerKey)
|
||||
.toSet();
|
||||
while (existingKeys.contains(providerKey())) {
|
||||
suffix += 1;
|
||||
}
|
||||
return settings.copyWith(
|
||||
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
|
||||
...settings.externalAcpEndpoints,
|
||||
ExternalAcpEndpointProfile(
|
||||
providerKey: providerKey(),
|
||||
label: appText(
|
||||
'自定义 ACP Endpoint $suffix',
|
||||
'Custom ACP Endpoint $suffix',
|
||||
),
|
||||
badge: '$suffix',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
Future<void> _showAddExternalAcpProviderWizard(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
) async {
|
||||
final settings = controller.settingsDraft;
|
||||
final nameController = TextEditingController();
|
||||
final endpointController = TextEditingController();
|
||||
var attemptedSubmit = false;
|
||||
try {
|
||||
final profile = await showDialog<ExternalAcpEndpointProfile>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final name = nameController.text.trim();
|
||||
final endpoint = endpointController.text.trim();
|
||||
final endpointValid =
|
||||
endpoint.isEmpty || isSupportedExternalAcpEndpoint(endpoint);
|
||||
final canSubmit =
|
||||
name.isNotEmpty && endpoint.isNotEmpty && endpointValid;
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
appText('添加自定义 ACP Endpoint', 'Add custom ACP endpoint'),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText(
|
||||
'通过向导新增更多外部 Agent Provider。先填写显示名称,再输入可访问的 ACP Server Endpoint。',
|
||||
'Use this wizard to add more external agent providers. Start with a display name, then enter a reachable ACP server endpoint.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
appText('步骤 1 · 显示名称', 'Step 1 · Display name'),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const ValueKey(
|
||||
'web-external-acp-wizard-name-field',
|
||||
),
|
||||
controller: nameController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: appText(
|
||||
'例如:Claude Sonnet / Lab Agent',
|
||||
'For example: Claude Sonnet / Lab Agent',
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setDialogState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
appText(
|
||||
'步骤 2 · ACP Server Endpoint',
|
||||
'Step 2 · ACP Server Endpoint',
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const ValueKey(
|
||||
'web-external-acp-wizard-endpoint-field',
|
||||
),
|
||||
controller: endpointController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'ws://127.0.0.1:9001',
|
||||
errorText: attemptedSubmit && endpoint.isEmpty
|
||||
? appText(
|
||||
'请输入 ACP Server Endpoint。',
|
||||
'Enter an ACP server endpoint.',
|
||||
)
|
||||
: attemptedSubmit && !endpointValid
|
||||
? appText(
|
||||
'仅支持 ws / wss / http / https。',
|
||||
'Only ws / wss / http / https are supported.',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (_) => setDialogState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'支持协议:ws、wss、http、https。新增后会出现在下方列表,并和助手页的 provider 菜单保持一致。',
|
||||
'Supported schemes: ws, wss, http, https. The new entry appears in the list below and stays aligned with the assistant provider menu.',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text(appText('取消', 'Cancel')),
|
||||
),
|
||||
FilledButton(
|
||||
key: const ValueKey(
|
||||
'web-external-acp-wizard-confirm-button',
|
||||
),
|
||||
onPressed: canSubmit
|
||||
? () {
|
||||
Navigator.of(dialogContext).pop(
|
||||
buildCustomExternalAcpEndpointProfile(
|
||||
settings.externalAcpEndpoints,
|
||||
label: name,
|
||||
endpoint: endpoint,
|
||||
),
|
||||
);
|
||||
}
|
||||
: () {
|
||||
setDialogState(() {
|
||||
attemptedSubmit = true;
|
||||
});
|
||||
},
|
||||
child: Text(appText('添加', 'Add')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (profile == null) {
|
||||
return;
|
||||
}
|
||||
await controller.saveSettingsDraft(
|
||||
settings.copyWith(
|
||||
externalAcpEndpoints: <ExternalAcpEndpointProfile>[
|
||||
...settings.externalAcpEndpoints,
|
||||
profile,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
} finally {
|
||||
nameController.dispose();
|
||||
endpointController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildGatewayCard(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@ -15,6 +16,7 @@ import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/runtime/codex_runtime.dart';
|
||||
import 'package:xworkmate/runtime/device_identity_store.dart';
|
||||
import 'package:xworkmate/runtime/gateway_runtime.dart';
|
||||
import 'package:xworkmate/runtime/multi_agent_orchestrator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
@ -494,6 +496,52 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'AssistantPage keeps composer controls above the safe bottom inset',
|
||||
(WidgetTester tester) async {
|
||||
final controller = await createTestController(tester);
|
||||
const safeBottomInset = 36.0;
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return MediaQuery(
|
||||
data: mediaQuery.copyWith(
|
||||
padding: mediaQuery.padding.copyWith(bottom: safeBottomInset),
|
||||
viewPadding: mediaQuery.viewPadding.copyWith(
|
||||
bottom: safeBottomInset,
|
||||
),
|
||||
),
|
||||
child: AssistantPage(
|
||||
controller: controller,
|
||||
onOpenDetail: (_) {},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final pageRect = tester.getRect(find.byType(AssistantPage));
|
||||
final composerShell = find.byKey(const Key('assistant-composer-shell'));
|
||||
final submitButton = find.byKey(const Key('assistant-submit-button'));
|
||||
|
||||
expect(composerShell, findsOneWidget);
|
||||
expect(submitButton, findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(composerShell).bottom,
|
||||
moreOrLessEquals(pageRect.bottom, epsilon: 0.01),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(submitButton).bottom,
|
||||
lessThanOrEqualTo(
|
||||
tester.getRect(composerShell).bottom - safeBottomInset,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('AssistantPage keeps a minimal composer action menu', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@ -550,6 +598,58 @@ void main() {
|
||||
expect(find.text('远程 OpenClaw Gateway'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'AssistantPage clears submitted composer text before send completes',
|
||||
(WidgetTester tester) async {
|
||||
late final _PendingSendAppController controller;
|
||||
final sendGate = Completer<void>();
|
||||
await tester.runAsync(() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final store = createIsolatedTestStore(enableSecureStorage: false);
|
||||
controller = _PendingSendAppController(
|
||||
store: store,
|
||||
sendGate: sendGate,
|
||||
);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
while (controller.initializing) {
|
||||
if (stopwatch.elapsed > const Duration(seconds: 10)) {
|
||||
fail('controller did not finish initializing before timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
});
|
||||
addTearDown(() async {
|
||||
if (!sendGate.isCompleted) {
|
||||
sendGate.complete();
|
||||
}
|
||||
});
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
final composerInput = find.descendant(
|
||||
of: find.byKey(const Key('assistant-composer-input-area')),
|
||||
matching: find.byType(TextField),
|
||||
);
|
||||
expect(composerInput, findsOneWidget);
|
||||
|
||||
await tester.enterText(composerInput, '分析一下这个 bug');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(controller.sendCallCount, 1);
|
||||
expect(controller.lastSentMessage, isNotEmpty);
|
||||
expect(tester.widget<TextField>(composerInput).controller?.text, isEmpty);
|
||||
|
||||
sendGate.complete();
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'AssistantPage shows a persistent skill popover in single-agent mode and keeps thread selections isolated',
|
||||
(WidgetTester tester) async {
|
||||
@ -1341,6 +1441,38 @@ Future<void> _waitForCondition(bool Function() predicate) async {
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingSendAppController extends AppController {
|
||||
_PendingSendAppController({
|
||||
required SecureConfigStore store,
|
||||
required this.sendGate,
|
||||
}) : super(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
|
||||
final Completer<void> sendGate;
|
||||
int sendCallCount = 0;
|
||||
String lastSentMessage = '';
|
||||
|
||||
@override
|
||||
Future<void> sendChatMessage(
|
||||
String message, {
|
||||
String thinking = 'off',
|
||||
List<GatewayChatAttachmentPayload> attachments =
|
||||
const <GatewayChatAttachmentPayload>[],
|
||||
List<CollaborationAttachment> localAttachments =
|
||||
const <CollaborationAttachment>[],
|
||||
List<String> selectedSkillLabels = const <String>[],
|
||||
}) async {
|
||||
sendCallCount += 1;
|
||||
lastSentMessage = message;
|
||||
await sendGate.future;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
_FakeGatewayRuntime({required super.store})
|
||||
: super(identityStore: DeviceIdentityStore(store));
|
||||
|
||||
@ -309,7 +309,7 @@ void main() {
|
||||
find.byKey(const ValueKey('external-acp-provider-add-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('添加自定义 ACP Server Endpoint'), findsOneWidget);
|
||||
expect(find.text('添加更多自定义配置'), findsOneWidget);
|
||||
expect(find.textContaining('ws://127.0.0.1:9001'), findsWidgets);
|
||||
expect(find.text('标志'), findsNothing);
|
||||
expect(find.text('Badge'), findsNothing);
|
||||
@ -323,6 +323,44 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage ACP wizard adds a custom provider card', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
platform: TargetPlatform.macOS,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('ACP 外部接入').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('external-acp-provider-add-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('external-acp-wizard-name-field')),
|
||||
'Lab Agent',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('external-acp-wizard-endpoint-field')),
|
||||
'wss://lab.example.com/acp',
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('external-acp-wizard-confirm-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Lab Agent'), findsWidgets);
|
||||
expect(find.text('wss://lab.example.com/acp'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage skills authorization tab keeps only preset roots', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
|
||||
@ -548,6 +548,88 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController uses the recorded thread workspace for Single Agent runs',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-single-agent-thread-cwd-',
|
||||
);
|
||||
final defaultWorkspace = Directory(
|
||||
'${tempDirectory.path}/default-workspace',
|
||||
);
|
||||
final threadWorkspace = Directory(
|
||||
'${tempDirectory.path}/thread-workspace',
|
||||
);
|
||||
await defaultWorkspace.create(recursive: true);
|
||||
await threadWorkspace.create(recursive: true);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
workspacePath: defaultWorkspace.path,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: 'Main',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: threadWorkspace.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final runner = _FakeSingleAgentRunner(
|
||||
resolvedProvider: SingleAgentProvider.codex,
|
||||
result: const SingleAgentRunResult(
|
||||
provider: SingleAgentProvider.codex,
|
||||
output: 'THREAD_OK',
|
||||
success: true,
|
||||
errorMessage: '',
|
||||
shouldFallbackToAiChat: false,
|
||||
),
|
||||
);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
singleAgentRunner: runner,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.sendChatMessage('检查当前线程目录', thinking: 'low');
|
||||
|
||||
expect(runner.runCalls, 1);
|
||||
expect(runner.lastRequest?.workingDirectory, threadWorkspace.path);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('main'),
|
||||
threadWorkspace.path,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
import '../test_support.dart';
|
||||
|
||||
@ -72,4 +75,95 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController preserves recorded workspace refs when switching threads',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-workspace-ref-',
|
||||
);
|
||||
final mainWorkspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-main-thread-',
|
||||
);
|
||||
final taskWorkspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-task-thread-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (await mainWorkspace.exists()) {
|
||||
await mainWorkspace.delete(recursive: true);
|
||||
}
|
||||
if (await taskWorkspace.exists()) {
|
||||
await taskWorkspace.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 1,
|
||||
title: 'Main',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: mainWorkspace.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:artifact-thread',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: 2,
|
||||
title: 'Artifact Thread',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
workspaceRef: taskWorkspace.path,
|
||||
workspaceRefKind: WorkspaceRefKind.localPath,
|
||||
),
|
||||
]);
|
||||
|
||||
final controller = AppController(store: store);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (controller.initializing) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('controller did not initialize in time');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('main'),
|
||||
mainWorkspace.path,
|
||||
);
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('draft:artifact-thread'),
|
||||
taskWorkspace.path,
|
||||
);
|
||||
|
||||
await controller.switchSession('draft:artifact-thread');
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('draft:artifact-thread'),
|
||||
taskWorkspace.path,
|
||||
);
|
||||
|
||||
await controller.switchSession('main');
|
||||
expect(
|
||||
controller.assistantWorkspaceRefForSession('main'),
|
||||
mainWorkspace.path,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,21 +63,21 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy claude and gemini entries migrate into custom endpoints', () {
|
||||
test('empty legacy claude and gemini entries are dropped', () {
|
||||
final normalized = normalizeExternalAcpEndpoints(
|
||||
profiles: const <ExternalAcpEndpointProfile>[
|
||||
ExternalAcpEndpointProfile(
|
||||
providerKey: 'claude',
|
||||
label: 'Claude',
|
||||
badge: 'Cl',
|
||||
endpoint: 'ws://127.0.0.1:9011',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
),
|
||||
ExternalAcpEndpointProfile(
|
||||
providerKey: 'gemini',
|
||||
label: 'Gemini',
|
||||
badge: 'G',
|
||||
endpoint: 'ws://127.0.0.1:9012',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
@ -88,14 +88,94 @@ void main() {
|
||||
const <String>['codex', 'opencode'],
|
||||
);
|
||||
expect(
|
||||
normalized
|
||||
.where((item) => item.providerKey.startsWith('custom-agent-'))
|
||||
.map((item) => item.label)
|
||||
.toList(growable: false),
|
||||
const <String>['Claude', 'Gemini'],
|
||||
normalized.where(
|
||||
(item) => item.providerKey.startsWith('custom-agent-'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect(normalized.any((item) => item.providerKey == 'claude'), isFalse);
|
||||
expect(normalized.any((item) => item.providerKey == 'gemini'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'configured legacy claude and gemini entries migrate into custom endpoints',
|
||||
() {
|
||||
final normalized = normalizeExternalAcpEndpoints(
|
||||
profiles: const <ExternalAcpEndpointProfile>[
|
||||
ExternalAcpEndpointProfile(
|
||||
providerKey: 'claude',
|
||||
label: 'Claude',
|
||||
badge: 'Cl',
|
||||
endpoint: 'wss://claude.example.com/acp',
|
||||
enabled: true,
|
||||
),
|
||||
ExternalAcpEndpointProfile(
|
||||
providerKey: 'gemini',
|
||||
label: 'Gemini',
|
||||
badge: 'G',
|
||||
endpoint: 'wss://gemini.example.com/acp',
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
normalized
|
||||
.where((item) => item.providerKey.startsWith('custom-agent-'))
|
||||
.map((item) => item.label)
|
||||
.toList(growable: false),
|
||||
const <String>['Claude', 'Gemini'],
|
||||
);
|
||||
expect(normalized.any((item) => item.providerKey == 'claude'), isFalse);
|
||||
expect(normalized.any((item) => item.providerKey == 'gemini'), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'custom endpoint builder validates sequential keys and label fallback',
|
||||
() {
|
||||
final profile = buildCustomExternalAcpEndpointProfile(
|
||||
SettingsSnapshot.defaults().externalAcpEndpoints,
|
||||
label: '',
|
||||
endpoint: 'wss://lab.example.com/acp',
|
||||
);
|
||||
|
||||
expect(profile.providerKey, 'custom-agent-3');
|
||||
expect(profile.label, 'Custom ACP Endpoint 3');
|
||||
expect(profile.endpoint, 'wss://lab.example.com/acp');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'available single-agent providers follow normalized endpoint settings',
|
||||
() {
|
||||
final snapshot = SettingsSnapshot.defaults().copyWith(
|
||||
externalAcpEndpoints: normalizeExternalAcpEndpoints(
|
||||
profiles: <ExternalAcpEndpointProfile>[
|
||||
...SettingsSnapshot.defaults().externalAcpEndpoints,
|
||||
buildCustomExternalAcpEndpointProfile(
|
||||
SettingsSnapshot.defaults().externalAcpEndpoints,
|
||||
label: 'Lab Agent',
|
||||
endpoint: 'wss://lab.example.com/acp',
|
||||
),
|
||||
const ExternalAcpEndpointProfile(
|
||||
providerKey: 'claude',
|
||||
label: 'Claude',
|
||||
badge: 'Cl',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
snapshot.availableSingleAgentProviders
|
||||
.map((item) => item.label)
|
||||
.toList(),
|
||||
const <String>['Codex', 'OpenCode', 'Lab Agent'],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -178,7 +178,7 @@ void main() {
|
||||
find.byKey(const ValueKey('web-external-acp-provider-add-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('添加自定义 ACP Server Endpoint'), findsOneWidget);
|
||||
expect(find.text('添加更多自定义配置'), findsOneWidget);
|
||||
expect(find.text('标志'), findsNothing);
|
||||
expect(find.text('Badge'), findsNothing);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user