xworkmate-app/lib/app/app_controller_web_gateway_chat.dart
2026-04-08 18:14:26 +08:00

444 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/go_task_service_client.dart';
import '../runtime/runtime_models.dart';
import '../web/web_acp_client.dart';
import '../web/web_ai_gateway_client.dart';
import '../web/web_artifact_proxy_client.dart';
import '../web/web_relay_gateway_client.dart';
import '../web/web_session_repository.dart';
import '../web/web_store.dart';
import '../web/web_workspace_controllers.dart';
import 'app_capabilities.dart';
import 'ui_feature_manifest.dart';
import 'app_controller_web_core.dart';
import 'app_controller_web_sessions.dart';
import 'app_controller_web_workspace.dart';
import 'app_controller_web_session_actions.dart';
import 'app_controller_web_gateway_config.dart';
import 'app_controller_web_gateway_relay.dart';
import 'app_controller_web_helpers.dart';
extension AppControllerWebGatewayChat on AppController {
Future<void> sendMessage(
String rawMessage, {
String thinking = 'medium',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
List<String> selectedSkillLabels = const <String>[],
bool useMultiAgent = false,
}) async {
final trimmed = rawMessage.trim();
if (trimmed.isEmpty) {
return;
}
await ensureWebTaskThreadBindingInternal(currentSessionKeyInternal);
if (assistantWorkspacePathForSession(
currentSessionKeyInternal,
).trim().isEmpty) {
final error = StateError(
appText(
'当前线程缺少工作路径,无法运行。',
'This thread has no workspace path, so it cannot run.',
),
);
lastAssistantErrorInternal = error.message.toString();
notifyChangedInternal();
throw error;
}
const maxAttachmentBytes = 10 * 1024 * 1024;
final totalAttachmentBytes = attachments.fold<int>(
0,
(total, item) => total + base64SizeInternal(item.content),
);
if (totalAttachmentBytes > maxAttachmentBytes) {
lastAssistantErrorInternal = appText(
'附件总大小超过 10MB请减少附件后重试。',
'Attachments exceed the 10MB limit. Remove some files and try again.',
);
notifyChangedInternal();
return;
}
final sessionKey = normalizedSessionKeyInternal(currentSessionKeyInternal);
await enqueueThreadTurnInternal<void>(sessionKey, () async {
lastAssistantErrorInternal = null;
final target = assistantExecutionTargetForSession(sessionKey);
final current =
threadRecordsInternal[sessionKey] ??
newRecordInternal(target: target);
final nextMessages = <GatewayChatMessage>[
...current.messages,
GatewayChatMessage(
id: messageIdInternal(),
role: 'user',
text: trimmed,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
];
upsertThreadRecordInternal(
sessionKey,
messages: nextMessages,
executionTarget: target,
title: deriveThreadTitleInternal(
current.title,
nextMessages,
hasCustomTitle:
(settingsInternal.assistantCustomTaskTitles[sessionKey]?.trim() ??
'')
.isNotEmpty,
),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
pendingSessionKeysInternal.add(sessionKey);
await persistThreadsInternal();
notifyChangedInternal();
try {
if (useMultiAgent && settingsInternal.multiAgent.enabled) {
await runMultiAgentCollaboration(
rawPrompt: trimmed,
composedPrompt: trimmed,
attachments: attachments,
selectedSkillLabels: selectedSkillLabels,
);
return;
}
if (target == AssistantExecutionTarget.singleAgent) {
await executeGoTaskServiceRunInternal(
sessionKey: sessionKey,
prompt: trimmed,
target: target,
provider: singleAgentProviderForSession(sessionKey),
model: assistantModelForSession(sessionKey),
thinking: thinking,
attachments: attachments,
selectedSkillLabels: selectedSkillLabels,
);
} else {
await executeGoTaskServiceRunInternal(
sessionKey: sessionKey,
prompt: trimmed,
target: target,
provider: SingleAgentProvider.auto,
model: assistantModelForSession(sessionKey),
thinking: thinking,
attachments: attachments,
selectedSkillLabels: selectedSkillLabels,
);
}
} catch (error) {
appendAssistantMessageInternal(
sessionKey: sessionKey,
text: error.toString(),
error: true,
);
upsertThreadRecordInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
lastAssistantErrorInternal = error.toString();
pendingSessionKeysInternal.remove(sessionKey);
streamingTextBySessionInternal.remove(sessionKey);
await persistThreadsInternal();
notifyChangedInternal();
}
});
}
Future<void> runMultiAgentCollaboration({
required String rawPrompt,
required String composedPrompt,
required List<GatewayChatAttachmentPayload> attachments,
required List<String> selectedSkillLabels,
}) async {
final sessionKey = normalizedSessionKeyInternal(currentSessionKeyInternal);
await enqueueThreadTurnInternal<void>(sessionKey, () async {
multiAgentRunPendingInternal = true;
acpBusyInternal = true;
pendingSessionKeysInternal.add(sessionKey);
notifyChangedInternal();
try {
final result = await goTaskServiceClientInternal.executeTask(
_buildGoTaskServiceRequestInternal(
sessionKey: sessionKey,
prompt: composedPrompt,
target: assistantExecutionTargetForSession(sessionKey),
provider: SingleAgentProvider.auto,
model: assistantModelForSession(sessionKey),
thinking: 'medium',
attachments: attachments,
selectedSkills: selectedSkillLabels,
routing: buildWebExternalAcpRoutingForSessionInternal(
sessionKey,
explicitExecutionTarget: 'multiAgent',
),
routingHint: 'multi-agent',
collaborationMode: GoTaskServiceCollaborationMode.multiAgent,
),
onUpdate: (update) {
if (update.isDelta) {
appendStreamingTextInternal(sessionKey, update.text);
notifyChangedInternal();
}
},
);
final summaryText = result.message.trim().isNotEmpty
? result.message.trim()
: appText('多智能体协作已完成。', 'Multi-agent collaboration completed.');
appendAssistantMessageInternal(
sessionKey: sessionKey,
text: summaryText,
error: false,
);
} catch (error) {
appendAssistantMessageInternal(
sessionKey: sessionKey,
text: error.toString(),
error: true,
);
lastAssistantErrorInternal = error.toString();
} finally {
multiAgentRunPendingInternal = false;
acpBusyInternal = false;
pendingSessionKeysInternal.remove(sessionKey);
clearStreamingTextInternal(sessionKey);
upsertThreadRecordInternal(
sessionKey,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: lastAssistantErrorInternal == null
? 'success'
: 'error',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await persistThreadsInternal();
notifyChangedInternal();
}
});
}
Future<void> selectDirectModel(String model) async {
final trimmed = model.trim();
if (trimmed.isEmpty) {
return;
}
await selectAssistantModel(trimmed);
settingsInternal = settingsInternal.copyWith(defaultModel: trimmed);
await persistSettingsInternal();
notifyChangedInternal();
}
Future<void> executeGoTaskServiceRunInternal({
required String sessionKey,
required String prompt,
required AssistantExecutionTarget target,
required SingleAgentProvider provider,
required String model,
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
required List<String> selectedSkillLabels,
}) async {
final selectedSkills = selectedSkillLabels
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
final request = _buildGoTaskServiceRequestInternal(
sessionKey: sessionKey,
prompt: prompt,
target: target,
provider: provider,
model: model,
thinking: thinking,
attachments: attachments,
selectedSkills: selectedSkills,
routing: buildWebExternalAcpRoutingForSessionInternal(sessionKey),
routingHint: target == AssistantExecutionTarget.singleAgent
? 'single-agent'
: 'gateway',
);
try {
final result = await goTaskServiceClientInternal.executeTask(
request,
onUpdate: (update) {
if (update.isDelta) {
appendStreamingTextInternal(sessionKey, update.text);
notifyChangedInternal();
}
},
);
final message = result.message.trim();
if (!result.success && result.errorMessage.trim().isNotEmpty) {
throw Exception(result.errorMessage.trim());
}
if (message.isEmpty) {
throw Exception(
appText(
'Go Task Service 没有返回可显示的输出。',
'Go Task Service returned no displayable output.',
),
);
}
upsertThreadRecordInternal(
sessionKey,
gatewayEntryState: goTaskServiceGatewayEntryState(
requestedTarget: target,
result: result,
),
latestResolvedRuntimeModel: result.resolvedModel.trim(),
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: 'success',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
appendAssistantMessageInternal(
sessionKey: sessionKey,
text: message,
error: false,
);
} finally {
clearStreamingTextInternal(sessionKey);
}
}
GoTaskServiceRequest _buildGoTaskServiceRequestInternal({
required String sessionKey,
required String prompt,
required AssistantExecutionTarget target,
required SingleAgentProvider provider,
required String model,
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
required List<String> selectedSkills,
required ExternalCodeAgentAcpRoutingConfig routing,
required String routingHint,
GoTaskServiceCollaborationMode collaborationMode =
GoTaskServiceCollaborationMode.standard,
}) {
return GoTaskServiceRequest(
sessionId: sessionKey,
threadId: sessionKey,
target: target,
prompt: prompt,
workingDirectory: assistantWorkspacePathForSession(sessionKey),
model: model,
thinking: thinking,
selectedSkills: selectedSkills,
inlineAttachments: attachments,
localAttachments: const <CollaborationAttachment>[],
aiGatewayBaseUrl: settingsInternal.aiGateway.baseUrl.trim(),
aiGatewayApiKey: aiGatewayApiKeyCacheInternal.trim(),
agentId: selectedAgentId,
metadata: <String, dynamic>{
if (selectedSkills.isNotEmpty) 'selectedSkills': selectedSkills,
},
routing: routing,
routingHint: routingHint,
provider: provider,
collaborationMode: collaborationMode,
);
}
ExternalCodeAgentAcpRoutingConfig
buildWebExternalAcpRoutingForSessionInternal(
String sessionKey, {
String? explicitExecutionTarget,
}) {
final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey);
final thread = threadRecordsInternal[normalizedSessionKey];
final sessionTarget = assistantExecutionTargetForSession(
normalizedSessionKey,
);
final preferredGatewayTarget = switch (sessionTarget) {
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
AssistantExecutionTarget.singleAgent => 'remote',
};
final availableSkills =
assistantImportedSkillsForSession(normalizedSessionKey)
.map(
(item) => ExternalCodeAgentAcpAvailableSkill(
id: item.key,
label: item.label,
description: item.description,
),
)
.toList(growable: false);
final selectedSkillKeys = assistantSelectedSkillKeysForSession(
normalizedSessionKey,
).toSet();
final selectedSkills =
assistantImportedSkillsForSession(normalizedSessionKey)
.where((item) => selectedSkillKeys.contains(item.key))
.map((item) => item.label.trim().isNotEmpty ? item.label : item.key)
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
final resolvedExplicitProviderId =
thread?.hasExplicitProviderSelection ?? false
? singleAgentProviderForSession(normalizedSessionKey).providerId
: '';
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
? assistantModelForSession(normalizedSessionKey)
: '';
final resolvedExplicitSkills = thread?.hasExplicitSkillSelection ?? false
? selectedSkills
: const <String>[];
final hasAnyExplicitSelection =
(thread?.hasExplicitExecutionTargetSelection ?? false) ||
resolvedExplicitProviderId.isNotEmpty ||
resolvedExplicitModel.trim().isNotEmpty ||
resolvedExplicitSkills.isNotEmpty;
final resolvedExplicitExecutionTarget =
explicitExecutionTarget?.trim().isNotEmpty == true
? explicitExecutionTarget!.trim()
: hasAnyExplicitSelection
? _webRoutingExecutionTargetValue(
assistantExecutionTargetForSession(normalizedSessionKey),
)
: '';
final hasExplicitSelection =
resolvedExplicitExecutionTarget.isNotEmpty ||
resolvedExplicitProviderId.isNotEmpty ||
resolvedExplicitModel.trim().isNotEmpty ||
resolvedExplicitSkills.isNotEmpty;
if (!hasExplicitSelection) {
return ExternalCodeAgentAcpRoutingConfig.auto(
preferredGatewayTarget: preferredGatewayTarget,
availableSkills: availableSkills,
);
}
return ExternalCodeAgentAcpRoutingConfig(
mode: ExternalCodeAgentAcpRoutingMode.explicit,
preferredGatewayTarget: preferredGatewayTarget,
explicitExecutionTarget: resolvedExplicitExecutionTarget,
explicitProviderId: resolvedExplicitProviderId,
explicitModel: resolvedExplicitModel,
explicitSkills: resolvedExplicitSkills,
allowSkillInstall: false,
availableSkills: availableSkills,
);
}
String _webRoutingExecutionTargetValue(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.singleAgent => 'singleAgent',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
};
}
}