Wox/wox.ui.flutter/wox/lib/controllers/wox_ai_chat_controller.dart

620 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:uuid/v4.dart';
import 'package:wox/api/wox_api.dart';
import 'package:wox/controllers/wox_list_controller.dart';
import 'package:wox/controllers/wox_launcher_controller.dart';
import 'package:wox/controllers/wox_setting_controller.dart';
import 'package:wox/entity/wox_ai.dart';
import 'package:wox/entity/wox_image.dart';
import 'package:wox/entity/wox_list_item.dart';
import 'package:wox/entity/wox_toolbar.dart';
import 'package:wox/enums/wox_ai_conversation_role_enum.dart';
import 'package:wox/enums/wox_image_type_enum.dart';
import 'package:wox/utils/log.dart';
class WoxAIChatController extends GetxController {
final Rx<WoxAIChatData> aiChatData = WoxAIChatData.empty().obs;
late final WoxListController<ChatSelectItem> chatSelectListController;
String tr(String key) {
return Get.find<WoxSettingController>().tr(key);
}
// Controllers and focus nodes
final TextEditingController textController = TextEditingController();
final WoxLauncherController launcherController = Get.find<WoxLauncherController>();
final FocusNode aiChatFocusNode = FocusNode();
final ScrollController aiChatScrollController = ScrollController();
final RxList<AIModel> aiModels = <AIModel>[].obs;
// State for chat select panel
final RxBool isShowChatSelectPanel = false.obs;
final RxString currentChatSelectCategory = "".obs; // models, tools or empty
// State for tool usage
final RxSet<String> selectedTools = <String>{}.obs;
final RxList<AIMCPTool> availableTools = <AIMCPTool>[].obs;
final RxBool isLoadingTools = false.obs;
// State for agents
final RxList<AIAgent> availableAgents = <AIAgent>[].obs;
final RxBool isLoadingAgents = false.obs;
// Tool call expanded/collapsed states
final RxMap<String, bool> toolCallExpandedStates = <String, bool>{}.obs;
// Toggle tool call expanded/collapsed state
void toggleToolCallExpanded(String conversationId) {
if (toolCallExpandedStates.containsKey(conversationId)) {
toolCallExpandedStates[conversationId] = !toolCallExpandedStates[conversationId]!;
} else {
toolCallExpandedStates[conversationId] = true;
}
}
// Get tool call expanded/collapsed state
bool isToolCallExpanded(String conversationId) {
return toolCallExpandedStates[conversationId] ?? false;
}
WoxAIChatController() {
chatSelectListController = WoxListController<ChatSelectItem>(
onItemExecuted: _onChatSelectItemExecuted,
onFilterBoxEscPressed: (traceId) => hideChatSelectPanel(),
);
reloadChatResources(const UuidV4().generate());
}
void reloadChatResources(String traceId, {String resourceName = "all"}) {
Logger.instance.debug(traceId, "start reloading AI chat resources");
if (resourceName == "models") {
reloadAIModels(traceId);
} else if (resourceName == "tools") {
fetchAvailableTools(traceId);
} else if (resourceName == "agents") {
fetchAvailableAgents(traceId);
} else if (resourceName == "all") {
reloadAIModels(traceId);
fetchAvailableTools(traceId);
fetchAvailableAgents(traceId);
}
}
// Load available AI models
void reloadAIModels(String traceId) {
Logger.instance.debug(traceId, "start reloading ai models");
WoxApi.instance.findAIModels().then((models) {
aiModels.assignAll(models);
Logger.instance.debug(traceId, "reload ai models: ${aiModels.length}");
});
}
void _onChatSelectItemExecuted(String traceId, WoxListItem<ChatSelectItem> item) {
final chatSelectItem = item.data;
if (chatSelectItem.onExecute != null) {
chatSelectItem.onExecute!(traceId);
}
}
// Update chat select items based on current category
void updateChatSelectItems() {
final List<WoxListItem<ChatSelectItem>> items = [];
Logger.instance.debug(const UuidV4().generate(), "AI: Updating chat select items for category: ${currentChatSelectCategory.value}");
if (currentChatSelectCategory.isEmpty) {
items.add(WoxListItem<ChatSelectItem>(
id: "agents",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🤖"),
title: "Agent Selection",
subTitle: "",
tails: [],
isGroup: false,
data: ChatSelectItem(
id: "agents",
name: "Agent Selection",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🤖"),
isCategory: true,
children: [],
onExecute: (String traceId) {
currentChatSelectCategory.value = "agents";
chatSelectListController.clearFilter(traceId);
updateChatSelectItems();
}),
));
items.add(WoxListItem<ChatSelectItem>(
id: "models",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🤖"),
title: "Model Selection",
subTitle: "",
tails: [],
isGroup: false,
data: ChatSelectItem(
id: "models",
name: "Model Selection",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🤖"),
isCategory: true,
children: [],
onExecute: (String traceId) {
currentChatSelectCategory.value = "models";
chatSelectListController.clearFilter(traceId);
updateChatSelectItems();
}),
));
items.add(WoxListItem<ChatSelectItem>(
id: "tools",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🔧"),
title: "Tool Configuration",
subTitle: "",
tails: [],
isGroup: false,
data: ChatSelectItem(
id: "tools",
name: "Tool Configuration",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🔧"),
isCategory: true,
children: [],
onExecute: (String traceId) {
currentChatSelectCategory.value = "tools";
chatSelectListController.clearFilter(traceId);
updateChatSelectItems();
}),
));
} else if (currentChatSelectCategory.value == "models") {
// Show models grouped by provider
// Group models by provider
final modelsByProvider = <String, List<AIModel>>{};
for (final model in aiModels) {
modelsByProvider.putIfAbsent(model.provider, () => []).add(model);
}
// Sort providers
final providers = modelsByProvider.keys.toList()..sort();
// Add groups and models
for (final provider in providers) {
// Skip empty groups
if (modelsByProvider[provider]!.isEmpty) continue;
// Add provider group header
items.add(WoxListItem<ChatSelectItem>(
id: "group_$provider",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🏢"),
title: provider,
subTitle: "",
tails: [],
isGroup: true,
data: ChatSelectItem(
id: "group_$provider",
name: provider,
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🏢"),
isCategory: true,
children: [],
onExecute: null,
),
));
// Sort models within this provider
final models = modelsByProvider[provider]!;
models.sort((a, b) => a.name.compareTo(b.name));
// Add models for this provider
for (final model in models) {
items.add(WoxListItem<ChatSelectItem>(
id: "${model.provider}_${model.name}",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🤖"),
title: model.name,
subTitle: "",
tails: [],
isGroup: false,
data: ChatSelectItem(
id: "${model.provider}_${model.name}",
name: model.name,
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🤖"),
isCategory: false,
children: [],
onExecute: (String traceId) {
aiChatData.value.model.value = AIModel(name: model.name, provider: model.provider);
hideChatSelectPanel();
}),
));
}
}
} else if (currentChatSelectCategory.value == "tools") {
// Show tools
for (final tool in availableTools) {
// Check if this tool is selected to determine if we should show the checkmark
final bool isSelected = selectedTools.contains(tool.name);
items.add(WoxListItem<ChatSelectItem>(
id: tool.name,
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🔧"),
title: tool.name,
subTitle: "",
tails: isSelected ? [WoxListItemTail.image(WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: ""))] : [],
isGroup: false,
data: ChatSelectItem(
id: tool.name,
name: tool.name,
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: "🔧"),
isCategory: false,
children: [],
onExecute: (String traceId) {
if (selectedTools.contains(tool.name)) {
selectedTools.remove(tool.name);
} else {
selectedTools.add(tool.name);
}
// Update the items to reflect the change in selection status
updateChatSelectItems();
}),
));
}
} else if (currentChatSelectCategory.value == "agents") {
// Add "Cancel Selection" option at the top
items.add(WoxListItem<ChatSelectItem>(
id: "cancel_agent_selection",
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: ""),
title: tr("ui_ai_chat_cancel_agent_selection"),
subTitle: tr("ui_ai_chat_use_default_model_and_tools"),
tails: (aiChatData.value.agentName == null || aiChatData.value.agentName!.isEmpty)
? [WoxListItemTail.image(WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: ""))]
: [],
isGroup: false,
data: ChatSelectItem(
id: "cancel_agent_selection",
name: tr("ui_ai_chat_cancel_agent_selection"),
icon: WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: ""),
isCategory: false,
children: [],
onExecute: (String traceId) {
setCurrentAgent("");
hideChatSelectPanel();
}),
));
// Display all available agents
for (final agent in availableAgents) {
final bool isSelected = aiChatData.value.agentName == agent.name;
items.add(WoxListItem<ChatSelectItem>(
id: agent.name,
icon: agent.icon, // 使用agent自定义头像
title: agent.name,
subTitle: "Model: ${agent.model.name}",
tails: isSelected ? [WoxListItemTail.image(WoxImage(imageType: WoxImageTypeEnum.WOX_IMAGE_TYPE_EMOJI.code, imageData: ""))] : [],
isGroup: false,
data: ChatSelectItem(
id: agent.name,
name: agent.name,
icon: agent.icon, // 使用agent自定义头像
isCategory: false,
children: [],
onExecute: (String traceId) {
setCurrentAgent(agent.name);
hideChatSelectPanel();
}),
));
}
}
Logger.instance.debug(const UuidV4().generate(), "AI: Updating chat select list with ${items.length} items");
chatSelectListController.updateItems(const UuidV4().generate(), items);
}
// Show chat select panel
void showChatSelectPanel() {
Logger.instance.debug(const UuidV4().generate(), "AI: Showing chat select panel");
isShowChatSelectPanel.value = true;
currentChatSelectCategory.value = "";
updateChatSelectItems();
SchedulerBinding.instance.addPostFrameCallback((_) {
chatSelectListController.filterBoxFocusNode.requestFocus();
});
}
// Show models panel directly
void showModelsPanel() {
showChatSelectPanel();
currentChatSelectCategory.value = "models";
updateChatSelectItems();
SchedulerBinding.instance.addPostFrameCallback((_) {
chatSelectListController.filterBoxFocusNode.requestFocus();
});
}
// Show tools panel directly
void showToolsPanel() {
showChatSelectPanel();
currentChatSelectCategory.value = "tools";
updateChatSelectItems();
SchedulerBinding.instance.addPostFrameCallback((_) {
chatSelectListController.filterBoxFocusNode.requestFocus();
});
}
// Show agents panel directly
void showAgentsPanel() {
showChatSelectPanel();
currentChatSelectCategory.value = "agents";
updateChatSelectItems();
SchedulerBinding.instance.addPostFrameCallback((_) {
chatSelectListController.filterBoxFocusNode.requestFocus();
});
}
// Hide chat select panel
void hideChatSelectPanel() {
isShowChatSelectPanel.value = false;
chatSelectListController.clearFilter(const UuidV4().generate());
aiChatFocusNode.requestFocus();
}
// Scroll to bottom of AI chat
void scrollToBottomOfAiChat() {
if (aiChatScrollController.hasClients) {
aiChatScrollController.animateTo(
aiChatScrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
// Focus to chat input
void focusToChatInput(String traceId) {
Logger.instance.info(traceId, "focus to chat input");
SchedulerBinding.instance.addPostFrameCallback((_) {
aiChatFocusNode.requestFocus();
});
}
// Method to fetch available tools based on the current model
Future<void> fetchAvailableTools(String traceId) async {
Logger.instance.info(traceId, "start fetching AI tools");
if (isLoadingTools.value) return;
isLoadingTools.value = true;
try {
final tools = await WoxApi.instance.findAIMCPServerToolsAll();
availableTools.assignAll(tools);
// Default select all tools
selectedTools.assignAll(tools.map((tool) => tool.name).toSet());
Logger.instance.debug(const UuidV4().generate(), "AI: loaded ${tools.length} tools");
} catch (e, s) {
Logger.instance.error(const UuidV4().generate(), 'Error fetching AI tools: $e $s');
availableTools.clear();
selectedTools.clear();
} finally {
isLoadingTools.value = false;
}
}
// Method to fetch available agents
Future<void> fetchAvailableAgents(String traceId) async {
Logger.instance.info(traceId, "start fetching AI agents");
if (isLoadingAgents.value) return;
isLoadingAgents.value = true;
try {
final agents = await WoxApi.instance.findAIAgents();
availableAgents.assignAll(agents);
Logger.instance.debug(traceId, "AI: loaded ${agents.length} agents");
// Log each agent for debugging
for (final agent in agents) {
Logger.instance.debug(traceId, "AI: agent details - Name: ${agent.name}, Model: ${agent.model.name}");
}
// If currently displaying agent selection panel, update the list
if (isShowChatSelectPanel.value && currentChatSelectCategory.value == "agents") {
updateChatSelectItems();
}
} catch (e, s) {
Logger.instance.error(const UuidV4().generate(), 'AI: Error fetching AI agents: $e $s');
availableAgents.clear();
} finally {
isLoadingAgents.value = false;
}
}
// Method to set current agent
void setCurrentAgent(String agentName) {
Logger.instance.debug(const UuidV4().generate(), "AI: Setting current agent to: $agentName");
aiChatData.value.agentName = agentName;
// If an agent is selected, try to get agent details
if (agentName.isNotEmpty) {
bool agentFound = false;
for (var agent in availableAgents) {
if (agent.name == agentName) {
Logger.instance.debug(const UuidV4().generate(), "AI: Found agent: ${agent.name}, setting model to ${agent.model.name} and tools to ${agent.tools.length} tools");
aiChatData.value.model.value = agent.model;
aiChatData.value.tools = agent.tools;
agentFound = true;
break;
}
}
if (!agentFound) {
Logger.instance.error(const UuidV4().generate(), "AI: Agent with name $agentName not found in available agents");
}
} else {
Logger.instance.debug(const UuidV4().generate(), "AI: No agent selected (empty agentName), setting default model and all tools");
_setDefaultModel();
// Select all available tools
selectedTools.clear();
selectedTools.addAll(availableTools.map((tool) => tool.name).toSet());
aiChatData.value.tools = selectedTools.toList();
}
}
Future<void> _setDefaultModel() async {
var defaultModel = await WoxApi.instance.findDefaultAIModel();
aiChatData.value.model.value = defaultModel;
}
// Get the name of the current agent
String getCurrentAgentName() {
if (aiChatData.value.agentName == null || aiChatData.value.agentName!.isEmpty) {
return "";
}
for (var agent in availableAgents) {
if (agent.name == aiChatData.value.agentName) {
return agent.name;
}
}
return "";
}
void sendMessage() {
var text = textController.text.trim();
// Check if AI model is selected
if (aiChatData.value.model.value.name.isEmpty) {
launcherController.showToolbarMsg(const UuidV4().generate(), ToolbarMsg(text: tr("ui_ai_chat_select_model"), displaySeconds: 3));
return;
}
// check if the text is empty
if (text.isEmpty) {
launcherController.showToolbarMsg(const UuidV4().generate(), ToolbarMsg(text: tr("ui_ai_chat_enter_message"), displaySeconds: 3));
return;
}
// append user message to chat data
aiChatData.value.conversations.add(WoxAIChatConversation(
id: const UuidV4().generate(),
role: WoxAIChatConversationRoleEnum.WOX_AIChat_CONVERSATION_ROLE_USER.value,
text: text,
images: [],
timestamp: DateTime.now().millisecondsSinceEpoch,
toolCallInfo: ToolCallInfo.empty(),
));
aiChatData.value.updatedAt = DateTime.now().millisecondsSinceEpoch;
textController.clear();
SchedulerBinding.instance.addPostFrameCallback((_) {
scrollToBottomOfAiChat();
});
aiChatData.value.tools = selectedTools.toList();
WoxApi.instance.sendChatRequest(aiChatData.value);
}
String formatTimestamp(int timestamp) {
final date = DateTime.fromMillisecondsSinceEpoch(timestamp);
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
void handleChatResponse(String traceId, WoxAIChatData data) {
// Update the chat data with the response
if (data.id == aiChatData.value.id) {
aiChatData.value.title = data.title;
aiChatData.value.conversations.assignAll(data.conversations);
aiChatData.value.updatedAt = data.updatedAt;
if (data.agentName != null) {
aiChatData.value.agentName = data.agentName;
}
// if the scrollbar is already at the bottom, scroll to bottom, otherwise, do nothing
if (aiChatScrollController.hasClients && aiChatScrollController.position.pixels == aiChatScrollController.position.maxScrollExtent) {
// Scroll to bottom after a short delay to ensure the new message is rendered
SchedulerBinding.instance.addPostFrameCallback((_) {
scrollToBottomOfAiChat();
});
}
}
}
// Copy message content to clipboard
void copyMessageContent(WoxAIChatConversation message) {
Clipboard.setData(ClipboardData(text: message.text));
launcherController.showToolbarMsg(const UuidV4().generate(), ToolbarMsg(text: tr("ui_ai_chat_message_copied"), displaySeconds: 2));
}
// Regenerate AI response for a specific message or the last user message
void regenerateAIResponse(String messageId) {
int userMessageIndex = -1;
// Find the AI message and its corresponding user message
int aiMessageIndex = aiChatData.value.conversations.indexWhere((m) => m.id == messageId);
if (aiMessageIndex == -1) {
launcherController.showToolbarMsg(const UuidV4().generate(), ToolbarMsg(text: tr("ui_ai_chat_message_not_found"), displaySeconds: 3));
return;
}
// Find the user message that comes before this AI message
for (int i = aiMessageIndex - 1; i >= 0; i--) {
if (aiChatData.value.conversations[i].role == WoxAIChatConversationRoleEnum.WOX_AIChat_CONVERSATION_ROLE_USER.value) {
userMessageIndex = i;
break;
}
}
if (userMessageIndex == -1) {
launcherController.showToolbarMsg(const UuidV4().generate(), ToolbarMsg(text: tr("ui_ai_chat_no_user_message_to_regenerate"), displaySeconds: 3));
return;
}
// Remove all messages after the user message
if (userMessageIndex < aiChatData.value.conversations.length - 1) {
aiChatData.value.conversations.removeRange(userMessageIndex + 1, aiChatData.value.conversations.length);
}
// Send the chat request to regenerate the response
aiChatData.value.tools = selectedTools.toList();
WoxApi.instance.sendChatRequest(aiChatData.value);
}
// Edit user message
void editUserMessage(WoxAIChatConversation message) {
// Set the text controller to the message content
textController.text = message.text;
// Find the index of the message
int messageIndex = aiChatData.value.conversations.indexWhere((m) => m.id == message.id);
if (messageIndex == -1) {
launcherController.showToolbarMsg(const UuidV4().generate(), ToolbarMsg(text: tr("ui_ai_chat_message_not_found"), displaySeconds: 2));
return;
}
// Remove this message and all subsequent messages
if (messageIndex < aiChatData.value.conversations.length - 1) {
aiChatData.value.conversations.removeRange(messageIndex, aiChatData.value.conversations.length);
} else {
// If it's the last message, just remove it
aiChatData.value.conversations.removeLast();
}
// Focus on the text input
SchedulerBinding.instance.addPostFrameCallback((_) {
aiChatFocusNode.requestFocus();
});
}
@override
void onClose() {
textController.dispose();
chatSelectListController.dispose();
aiChatFocusNode.dispose();
aiChatScrollController.dispose();
super.onClose();
}
}