diff --git a/README.md b/README.md index 689398f9..7dd6e629 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A Windows desktop application that integrates chat, text-to-image, text-to-speech, and machine translation, supports the current mainstream AI services, and offers an excellent desktop AI experience. + + English · [简体中文](./README.zh-CN.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 14c0cb2e..8e413698 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,6 +8,8 @@ 集聊天、文生图、文本转语音、机器翻译于一身的 Windows 桌面应用,支持目前主流的 AI 服务,提供优秀的桌面 AI 体验。 + + [English](./README.md) · 简体中文 diff --git a/docs/assets/en/create-group-item.png b/docs/assets/en/create-group-item.png new file mode 100644 index 00000000..4768be1e Binary files /dev/null and b/docs/assets/en/create-group-item.png differ diff --git a/docs/assets/en/group-dialog.png b/docs/assets/en/group-dialog.png new file mode 100644 index 00000000..03d612c1 Binary files /dev/null and b/docs/assets/en/group-dialog.png differ diff --git a/docs/assets/zh/create-group-item.png b/docs/assets/zh/create-group-item.png new file mode 100644 index 00000000..2c1240d5 Binary files /dev/null and b/docs/assets/zh/create-group-item.png differ diff --git a/docs/assets/zh/group-dialog.png b/docs/assets/zh/group-dialog.png new file mode 100644 index 00000000..10301a7a Binary files /dev/null and b/docs/assets/zh/group-dialog.png differ diff --git a/docs/en/group-chat.md b/docs/en/group-chat.md new file mode 100644 index 00000000..73bc8437 --- /dev/null +++ b/docs/en/group-chat.md @@ -0,0 +1,80 @@ +# Group Chat (Experiment) + +> [!TIP] +> Group chat is an experimental feature that became available in version `2.2307.1.0`. Currently, it is not applicable to all models. For specific limitations, please refer to [Limitations](#limitations). + +We know that models can take on specific roles to perform certain tasks. Have you ever thought about letting different models play different roles and then working together to solve target problems? + +Group chat is an exploration in this direction. + +## Prerequisites + +Since it's a group chat, you need to create "group members" first. + +These group members are **agents**. You need to create two or more agents according to the guidelines in [Agent and Preset](./agent-preset) before you can gather them into a group. + +## Limitations + +1. Some models only support one-on-one interactions (their message queue strictly requires an alternating sequence of one AI message and one user message). Such models (e.g., ERNIE `QianFan`) do not support group chats because multiple agent messages are generated at once in a group. +2. Different models have different context windows. Please try to place models with smaller context windows at the front of the group members list. +3. Group members speak in order, and users can only adjust the target after each round of speaking but cannot interrupt in the middle (though they can cancel in the middle). + +## Simple Example + +### Creating agents + +Next, we can create three agents to help us generate an HTML calculator (taken from [Step by Step guide to develop AI Multi-Agent system using Microsoft Semantic Kernel and GPT-4](https://medium.com/@akshaykokane09/step-by-step-guide-to-develop-ai-multi-agent-system-using-microsoft-semantic-kernel-and-gpt-4o-f5991af40ea6)). + +| Agent Name | Program Manager | +| -------------- | ---------------- | +| Instruction | You are a program manager which will take the requirement and create a plan for creating an app. The Program Manager understands the user requirements and forms the detailed documents with requirements and costing. | + +| Agent Name | Software Engineer | +| -------------- | ----------------- | +| Instruction | You are a Software Engineer, and your goal is to develop a web app using HTML and JavaScript (JS) by considering all the requirements given by the Program Manager. | + +| Agent Name | Manager | +| -------------- | --------------- | +| Instruction | You are a manager who will review the software engineer's code and make sure all client requirements are completed. Once all client requirements are completed, you can approve the request by just responding "approve". | + +When creating agents, you can set their models freely as long as the models meet the [Limitations](#limitations). + +### Creating a Group + +
+ +![Creating a Group](../assets/en/create-group-item.png) + +
+ +Select `Create Group` from the overflow menu at the top of the left panel in the chat interface. + +Configure the basic settings in the group panel that pops up. + +![Group Panel](../assets/en/group-dialog.png) + +Besides choosing agents, you'll notice two other parameters: + +1. **Termination Text** + This is a `brake pad`. When the AI-generated text contains the termination text you set, the application will judge that the goal has been achieved and terminate the current group conversation. In this example, the project manager will ultimately determine if the goal has been achieved and give the "approve" verdict, so our termination text here is `approve`. +2. **Maximum Session Rounds** + A complete round is considered after all agents generate their content once. AI will negotiate for one round after another based on your set goal. To avoid token management getting out of control, you can set a maximum session round. Once it reaches this round, it will forcibly interrupt the AI discussion. Based on our preset, considering that the project manager might suggest modifications, we can set the maximum rounds to 5. + +> [!IMPORTANT] +> Currently, group sessions will call agents sequentially from top to bottom according to the order you set. So, please plan the execution order when creating the group. + +### Start the Conversation + +Once the group is created, you can find it in the left navigation panel of the chat interface. + +Click to start the conversation. + +In the input box, you can enter the following: + +```text +I want to develop a calculator app. It should have a basic calculator appearance and get final approval from the project manager. +``` + +Then, you will see the `Product Manager`, `Software Engineer`, and `Project Manager` speak in turn, ultimately generating code that includes `HTML`, `CSS`, and `JavaScript`. + +This is the charm of AI groups. \ No newline at end of file diff --git a/docs/group-chat.md b/docs/group-chat.md new file mode 100644 index 00000000..dde1be75 --- /dev/null +++ b/docs/group-chat.md @@ -0,0 +1,80 @@ +# 群组聊天(实验) + +> [!TIP] +> 群组聊天是一项实验性功能,在 `2.2307.1.0` 中开始提供,目前并不是所有模型都能适用,具体的限制条件请查看 [限制条件](#限制条件) + +我们知道,模型是可以扮演角色执行特定任务的,那么你是否想过,让不同的模型扮演不同的角色,然后让它们协同工作,解决目标问题呢? + +群组聊天就是对这个方向的探索。 + +## 前置条件 + +既然是群组聊天,那你就需要先创建“群员”。 + +这个群员就是 **助理** ,你需要先按照 [助理与预设](./agent-preset) 中的指引创建两个以上的助理,然后才能把它们归集到一个群组中。 + +## 限制条件 + +1. 有些模型仅支持一对一(其消息队列严格要求一条 AI 信息,一条用户信息交替),这类模型(比如文心一言)是不受群组支持的,因为群组内一次会产生多条助理信息。 +2. 不同模型的上下文窗口不同,请尽量把上下文窗口小的模型放在群成员前列。 +3. 群组成员按照先后顺序发言,用户只能在每轮发言结束后调整目标,而不能中途插入(但可以中途取消)。 + +## 简单示例 + +### 创建助理 + +接下来,我们可以创建三个助理,来帮助我们生成一个 HTML 的计算器。(取自 [Step by Step guide to develop AI Multi-Agent system using Microsoft Semantic Kernel and GPT-4o](https://medium.com/@akshaykokane09/step-by-step-guide-to-develop-ai-multi-agent-system-using-microsoft-semantic-kernel-and-gpt-4o-f5991af40ea6)) + +|助理名称|产品经理| +|-|-| +|指令|You are a program manager which will take the requirement and create a plan for creating app. Program Manager understands the user requirements and form the detail documents with requirements and costing.| + +|助理名称|软件工程师| +|-|-| +|指令|You are Software Engieer, and your goal is develop web app using HTML and JavaScript (JS) by taking into consideration all the requirements given by Program Manager.| + +|助理名称|项目经理| +|-|-| +|指令|You are manager which will review software engineer code, and make sure all client requirements are completed. Once all client requirements are completed, you can approve the request by just responding "approve"| + +创建助理时,你可以随意设定其模型,只要该模型符合 [限制条件](#限制条件) 即可。 + +### 创建群组 + +
+ +![创建群组](./assets/zh/create-group-item.png) + +
+ +在聊天界面左侧面板顶部的溢出菜单中选择 `创建群组`。 + +在弹出的群组面板中进行基本的配置。 + +![群组面板](./assets/zh/group-dialog.png) + +除了选择助理外,你会注意到还有两个参数: + +1. **终止文本** + 这是一个 `刹车片`,当 AI 生成的文本中出现你设定的终止文本后,应用会判断目标达成,然后终止当前的群组对话。在当前示例中,项目经理最终会判断目标是否达成,并给出 `approve` 的判定,所以我们这里的终止文本就是 `approve`。 +2. **最大会话轮次** + 所有助理依次生成完内容记作一轮,AI 会根据你设定的目标进行一轮又一轮的磋商,为了避免我们的 token 管理失控,你可以设定最大会话轮次,达到该轮次,则强行中断 AI 们的讨论。根据我们的预设,考虑到项目经理可能提出修改意见,这里我们可以把最大轮次设置为5。 + +> [!IMPORTANT] +> 目前群组会话会按照你设置的助理顺序从上往下依次调用,所以在创建群组时请规划好执行顺序。 + +### 开始对话 + +创建群组后,你就能在聊天界面的左侧导航面板中找到你创建的群组了。 + +点击即可开始对话。 + +在输入框中,我们可以输入以下内容: + +```text +我想开发一个计算器应用。具备基础的计算器外观,并从项目经理处获得最终许可。 +``` + +然后,你就可以看到 `产品经理`,`软件工程师` 和 `项目经理` 依次发言,最终生成了一份包含 `HTML`, `CSS` 和 `Javascript` 的代码。 + +这就是 AI 群组的魅力。 \ No newline at end of file diff --git a/src/Console/RodelChat.Console/ChatService.cs b/src/Console/RodelChat.Console/ChatService.cs index 7bcf955e..6ba24ddb 100644 --- a/src/Console/RodelChat.Console/ChatService.cs +++ b/src/Console/RodelChat.Console/ChatService.cs @@ -1,6 +1,7 @@ // Copyright (c) Rodel. All rights reserved. -using System.Text.Json; +#define USE_GROUP + using Microsoft.Extensions.Hosting; using RodelAgent.Interfaces; using RodelAgent.Statics; @@ -15,6 +16,7 @@ public sealed class ChatService : IHostedService { private readonly IChatClient _chatClient; + private readonly IChatParametersFactory _chatParametersFactory; private readonly IStringResourceToolkit _localizer; private ProviderType _currentType; private ChatSession _currentSession; @@ -23,10 +25,12 @@ public sealed class ChatService : IHostedService /// Initializes a new instance of the class. /// public ChatService( + IChatParametersFactory chatParametersFactory, IChatClient chatClient, IHostApplicationLifetime lifetime, IStringResourceToolkit localizer) { + _chatParametersFactory = chatParametersFactory; _chatClient = chatClient; _localizer = localizer; lifetime.ApplicationStopping.Register(_chatClient.Dispose); @@ -37,11 +41,28 @@ public async Task StartAsync(CancellationToken cancellationToken) { try { -#if USE_PRESET - var preset = AskSessionPreset(); - var session = _chatClient.CreateSession(preset); - _currentType = session.Provider; - await RunAIAsync(session); +#if USE_GROUP + var agents = CreateNewsAgents(); + var message = AskInput(); + var preset = new ChatGroupPreset + { + Agents = agents.Select(a => a.Id).ToList(), + Id = "group", + MaxRounds = 6, + Name = "Group", + TerminateText = ["approve", "批准"], + }; + var group = _chatClient.CreateSession(preset); + var chatMsg = ChatMessage.CreateUserMessage(message); + await _chatClient.SendGroupMessageAsync( + group.Id, + chatMsg, + (response) => + { + HandleMessageResponse(response); + }, + agents, + CancellationToken.None); #else var provider = AskProvider(); await RunAIAsync(provider); @@ -71,6 +92,111 @@ private async Task RunAIAsync(ChatSession session) await LoopMessageAsync(session); } + private List CreateCoderAgents() + { + _ = this; + var progamManagerText = + """ + You are a program manager which will take the requirement and create a plan for creating app. Program Manager understands the + user requirements and form the detail documents with requirements and costing. + """; + + var softwareEngineerText = + """ + You are Software Engieer, and your goal is develop web app using HTML and JavaScript (JS) by taking into consideration all + the requirements given by Program Manager. + """; + + var managerText = + """ + You are manager which will review software engineer code, and make sure all client requirements are completed. + Once all client requirements are completed, you can approve the request by just responding "approve" + """; + + var programManager = new ChatSessionPreset + { + Name = "Program Manager", + Provider = ProviderType.AzureOpenAI, + Model = "gpt-4o", + Id = "program-manager", + SystemInstruction = progamManagerText, + Parameters = _chatParametersFactory.CreateChatParameters(ProviderType.AzureOpenAI), + }; + + var softwareEngineer = new ChatSessionPreset + { + Name = "Software Engineer", + Provider = ProviderType.ZhiPu, + Model = "glm-4", + Id = "software-engineer", + SystemInstruction = softwareEngineerText, + Parameters = _chatParametersFactory.CreateChatParameters(ProviderType.ZhiPu), + }; + + var manager = new ChatSessionPreset + { + Name = "Manager", + Provider = ProviderType.AzureOpenAI, + Model = "gpt-4o", + Id = "manager", + SystemInstruction = managerText, + Parameters = _chatParametersFactory.CreateChatParameters(ProviderType.AzureOpenAI), + }; + + return [programManager, softwareEngineer, manager]; + } + + private List CreateNewsAgents() + { + var reporterText = + """ + You are a reporter which will take the news and create a news article. Reporter understands the news and form the detail article with + news and images. + """; + var editorText = + """ + You are editor, and your goal is to review the news article, and make sure all the news are correct. + """; + + var publishManagerText = + """ + You are publish manager which will review editor news article, and make sure all news are correct. + Once all news are correct, you can approve the request (with 'approve' keyword) and give final news article to publish. + """; + + var reporter = new ChatSessionPreset + { + Name = "Reporter", + Provider = ProviderType.AzureOpenAI, + Model = "gpt-4o", + Id = "reporter", + SystemInstruction = reporterText, + Parameters = _chatParametersFactory.CreateChatParameters(ProviderType.AzureOpenAI), + }; + + var editor = new ChatSessionPreset + { + Name = "Editor", + Provider = ProviderType.ZhiPu, + Model = "glm-4", + Id = "editor", + SystemInstruction = editorText, + Parameters = _chatParametersFactory.CreateChatParameters(ProviderType.ZhiPu), + }; + + var publishManager = new ChatSessionPreset + { + Name = "Publish Manager", + Provider = ProviderType.AzureOpenAI, + Model = "gpt-4o", + Id = "publish-manager", + SystemInstruction = publishManagerText, + Parameters = _chatParametersFactory.CreateChatParameters(ProviderType.AzureOpenAI), + }; + + return [reporter, editor, publishManager]; + } + private async Task LoopMessageAsync(ChatSession session) { #if USE_SYSTEM_PROMPT @@ -127,24 +253,6 @@ private ProviderType AskProvider() return provider; } - private ChatSessionPreset AskSessionPreset() - { - // Get presets file list in Presets folder without extension name. - var presetFiles = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "Presets"), "*.json"); - var presetNames = presetFiles.Select(p => Path.GetFileNameWithoutExtension(p)!).ToList(); - - var presetName = AnsiConsole.Prompt( - new SelectionPrompt() - .Title(GetString("SelectPreset")) - .PageSize(10) - .AddChoices(presetNames)); - - var presetFile = Path.Combine(AppContext.BaseDirectory, "Presets", $"{presetName}.json"); - var presetContent = File.ReadAllText(presetFile); - var preset = JsonSerializer.Deserialize(presetContent); - return preset; - } - private ChatModel AskModel(ProviderType type) { var models = _chatClient.GetModels(type); @@ -264,6 +372,11 @@ private void PrintAssistantMessage(ChatMessage response) Padding = new Padding(2, 2, 2, 2), }; + if (!string.IsNullOrEmpty(response.Author)) + { + panel.Header = new PanelHeader(response.Author); + } + AnsiConsole.Write(panel); } diff --git a/src/Core/RodelAgent.Context/Assets/chat.db b/src/Core/RodelAgent.Context/Assets/chat.db index 423720e2..927dad3a 100644 Binary files a/src/Core/RodelAgent.Context/Assets/chat.db and b/src/Core/RodelAgent.Context/Assets/chat.db differ diff --git a/src/Core/RodelAgent.Context/ChatDbContext.cs b/src/Core/RodelAgent.Context/ChatDbContext.cs index 4b6b3324..361527af 100644 --- a/src/Core/RodelAgent.Context/ChatDbContext.cs +++ b/src/Core/RodelAgent.Context/ChatDbContext.cs @@ -27,6 +27,11 @@ public sealed class ChatDbContext : DbContext /// public DbSet Sessions { get; set; } + /// + /// 群组会话列表. + /// + public DbSet Groups { get; set; } + /// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseSqlite($"Data Source={_dbPath}"); diff --git a/src/Core/RodelAgent.Context/DbService.cs b/src/Core/RodelAgent.Context/DbService.cs index a1c7d754..bc9cdcfb 100644 --- a/src/Core/RodelAgent.Context/DbService.cs +++ b/src/Core/RodelAgent.Context/DbService.cs @@ -67,16 +67,27 @@ public async Task> GetAllChatSessionAsync() return await _chatDb.Sessions.Select(p => p.Value).ToListAsync(); } + /// + /// 获取所有群组会话数据. + /// + /// JSON 列表. + public async Task> GetAllChatGroupAsync() + { + _chatDb ??= await MigrationUtils.GetChatDbAsync(_workingDirectory); + return await _chatDb.Groups.Select(p => p.Value).ToListAsync(); + } + /// /// 添加或更新聊天数据. /// /// 会话标识符. /// 会话 JSON 数据. + /// 是否为群组数据. /// . - public async Task AddOrUpdateChatDataAsync(string dataId, string value) + public async Task AddOrUpdateChatDataAsync(string dataId, string value, bool isGroup = false) { _chatDb ??= await MigrationUtils.GetChatDbAsync(_workingDirectory); - var dataset = _chatDb.Sessions; + var dataset = isGroup ? _chatDb.Groups : _chatDb.Sessions; var data = await dataset.FirstOrDefaultAsync(x => x.Id == dataId); if (data is null) { @@ -95,11 +106,12 @@ public async Task AddOrUpdateChatDataAsync(string dataId, string value) /// 移除聊天会话. /// /// 数据标识符. + /// 是否为群组消息. /// . - public async Task RemoveChatDataAsync(string dataId) + public async Task RemoveChatDataAsync(string dataId, bool isGroup = false) { _chatDb ??= await MigrationUtils.GetChatDbAsync(_workingDirectory); - var dataset = _chatDb.Sessions; + var dataset = isGroup ? _chatDb.Groups : _chatDb.Sessions; var data = await dataset.FirstOrDefaultAsync(x => x.Id == dataId); if (data is not null) { diff --git a/src/Core/RodelAgent.Context/MigrationUtils.cs b/src/Core/RodelAgent.Context/MigrationUtils.cs index 53db8692..223a89fb 100644 --- a/src/Core/RodelAgent.Context/MigrationUtils.cs +++ b/src/Core/RodelAgent.Context/MigrationUtils.cs @@ -1,5 +1,7 @@ // Copyright (c) Rodel. All rights reserved. +using Microsoft.EntityFrameworkCore; + namespace RodelAgent.Context; /// @@ -26,7 +28,9 @@ public static async Task GetSecretDbAsync(string workDir) public static async Task GetChatDbAsync(string workDir) { await CheckDatabaseExistInternalAsync("chat.db", workDir); - return new ChatDbContext(Path.Combine(workDir, "chat.db")); + var chatDbContext = new ChatDbContext(Path.Combine(workDir, "chat.db")); + await chatDbContext.Database.MigrateAsync(); + return chatDbContext; } /// diff --git a/src/Core/RodelAgent.Context/Migrations/20240626011246_ChatAddGroup.Designer.cs b/src/Core/RodelAgent.Context/Migrations/20240626011246_ChatAddGroup.Designer.cs new file mode 100644 index 00000000..1fdc3579 --- /dev/null +++ b/src/Core/RodelAgent.Context/Migrations/20240626011246_ChatAddGroup.Designer.cs @@ -0,0 +1,38 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RodelAgent.Context; + +#nullable disable + +namespace RodelAgent.Context.Migrations +{ + [DbContext(typeof(ChatDbContext))] + [Migration("20240626011246_ChatAddGroup")] + partial class ChatAddGroup + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.5"); + + modelBuilder.Entity("RodelAgent.Models.Common.Metadata", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Metadata"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/RodelAgent.Context/Migrations/20240626011246_ChatAddGroup.cs b/src/Core/RodelAgent.Context/Migrations/20240626011246_ChatAddGroup.cs new file mode 100644 index 00000000..11f2b717 --- /dev/null +++ b/src/Core/RodelAgent.Context/Migrations/20240626011246_ChatAddGroup.cs @@ -0,0 +1,46 @@ +// + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RodelAgent.Context.Migrations +{ + /// + public partial class ChatAddGroup : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_Sessions", + table: "Sessions"); + + migrationBuilder.RenameTable( + name: "Sessions", + newName: "Metadata"); + + migrationBuilder.AddPrimaryKey( + name: "PK_Metadata", + table: "Metadata", + column: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_Metadata", + table: "Metadata"); + + migrationBuilder.RenameTable( + name: "Metadata", + newName: "Sessions"); + + migrationBuilder.AddPrimaryKey( + name: "PK_Sessions", + table: "Sessions", + column: "Id"); + } + } +} diff --git a/src/Core/RodelAgent.Context/Migrations/ChatDbContextModelSnapshot.cs b/src/Core/RodelAgent.Context/Migrations/ChatDbContextModelSnapshot.cs index 3508b6e1..eb9915df 100644 --- a/src/Core/RodelAgent.Context/Migrations/ChatDbContextModelSnapshot.cs +++ b/src/Core/RodelAgent.Context/Migrations/ChatDbContextModelSnapshot.cs @@ -27,7 +27,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("Sessions"); + b.ToTable("Metadata"); }); #pragma warning restore 612, 618 } diff --git a/src/Core/RodelAgent.Interfaces/IStorageService.cs b/src/Core/RodelAgent.Interfaces/IStorageService.cs index 40332ebd..818f631a 100644 --- a/src/Core/RodelAgent.Interfaces/IStorageService.cs +++ b/src/Core/RodelAgent.Interfaces/IStorageService.cs @@ -134,6 +134,27 @@ Task SetAudioConfigAsync(audioConstants.ProviderType type, T config) /// . Task RemoveChatSessionAsync(string sessionId); + /// + /// 获取指定预设的群组会话. + /// + /// 预设标识符. + /// 会话列表. + Task?> GetChatGroupSessionsAsync(string presetId); + + /// + /// 添加或更新群组会话. + /// + /// 会话. + /// . + Task AddOrUpdateChatGroupSessionAsync(ChatGroup session); + + /// + /// 移除聊天群组会话. + /// + /// 会话标识符. + /// . + Task RemoveChatGroupSessionAsync(string sessionId); + /// /// 获取聊天会话预设. /// @@ -181,6 +202,33 @@ Task SetAudioConfigAsync(audioConstants.ProviderType type, T config) /// . Task RemoveChatAgentAsync(string agentId); + /// + /// 移除群组. + /// + /// 群组标识符. + /// . + Task RemoveChatGroupPresetAsync(string presetId); + + /// + /// 获取会话群组预设列表. + /// + /// 助理列表. + Task> GetChatGroupPresetsAsync(); + + /// + /// 获取指定 ID 的群组会话预设. + /// + /// 预设 ID. + /// . + Task GetChatGroupPresetByIdAsync(string presetId); + + /// + /// 添加或更新群组预设. + /// + /// 群组信息. + /// . + Task AddOrUpdateChatGroupPresetAsync(ChatGroupPreset preset); + /// /// 获取指定供应商的翻译会话. /// diff --git a/src/Core/RodelChat.Core/ChatClient.Helper.cs b/src/Core/RodelChat.Core/ChatClient.Helper.cs index 8917f8e2..467e5a4c 100644 --- a/src/Core/RodelChat.Core/ChatClient.Helper.cs +++ b/src/Core/RodelChat.Core/ChatClient.Helper.cs @@ -1,5 +1,6 @@ // Copyright (c) Rodel. All rights reserved. +using System.Text; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; using RodelAgent.Models.Abstractions; @@ -14,12 +15,6 @@ namespace RodelChat.Core; /// public sealed partial class ChatClient { - private static Microsoft.SemanticKernel.ChatMessageContent ConvertToKernelMessage(ChatMessage message) - { - var role = ConvertToRole(message.Role); - return new Microsoft.SemanticKernel.ChatMessageContent(role, ConvertToContentItemCollection(message.Content.ToArray())); - } - private static AuthorRole ConvertToRole(MessageRole role) => role switch { @@ -47,7 +42,7 @@ private static ChatMessageContentItemCollection ConvertToContentItemCollection(M return items; } - private static ChatHistory GetChatHistory(ChatSession session) + private ChatHistory GetChatHistory(ChatSession session) { var history = new ChatHistory(); if (!string.IsNullOrEmpty(session.SystemInstruction)) @@ -73,6 +68,18 @@ private static ChatHistory GetChatHistory(ChatSession session) return history; } + private Microsoft.SemanticKernel.ChatMessageContent ConvertToKernelMessage(ChatMessage message) + { + var role = ConvertToRole(message.Role); + var msg = new Microsoft.SemanticKernel.ChatMessageContent(role, ConvertToContentItemCollection(message.Content.ToArray())); + if (!string.IsNullOrEmpty(message.Author)) + { + msg.AuthorName = EncodeName(message.Author); + } + + return msg; + } + private PromptExecutionSettings GetExecutionSettings(ChatSession session) => GetProvider(session.Provider).ConvertExecutionSettings(session); @@ -134,4 +141,44 @@ private BaseFieldParameters GetChatParameters(ProviderType type, BaseFieldParame return parameters; } + + private string EncodeName(string input) + { + var encoded = new StringBuilder(); + foreach (var c in input) + { + if (_nameEncodePattern.IsMatch(c.ToString())) + { + encoded.Append(c); + } + else + { + encoded.Append('_').Append(((int)c).ToString("X4")); + } + } + + return encoded.ToString(); + } + + private string DecodeName(string input) + { + _ = this; + var decoded = new StringBuilder(); + for (var i = 0; i < input.Length; i++) + { + if (input[i] == '_') + { + var hexCode = input.Substring(i + 1, 4); + var charCode = Convert.ToInt32(hexCode, 16); + decoded.Append((char)charCode); + i += 4; + } + else + { + decoded.Append(input[i]); + } + } + + return decoded.ToString(); + } } diff --git a/src/Core/RodelChat.Core/ChatClient.Properties.cs b/src/Core/RodelChat.Core/ChatClient.Properties.cs index c561fe0d..746c5b23 100644 --- a/src/Core/RodelChat.Core/ChatClient.Properties.cs +++ b/src/Core/RodelChat.Core/ChatClient.Properties.cs @@ -1,5 +1,6 @@ // Copyright (c) Rodel. All rights reserved. +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using RodelChat.Interfaces.Client; using RodelChat.Models.Client; @@ -15,6 +16,8 @@ public sealed partial class ChatClient private readonly IChatParametersFactory _parameterFactory; private readonly ILogger _logger; private readonly List _dllPaths = new(); + private readonly Regex _nameEncodePattern = new Regex("^[a-zA-Z0-9_-]+$"); + private bool _disposedValue; private string _preferDllPath; @@ -22,4 +25,9 @@ public sealed partial class ChatClient /// 会话列表. /// public List Sessions { get; } + + /// + /// 群组会话列表. + /// + public List Groups { get; } } diff --git a/src/Core/RodelChat.Core/ChatClient.cs b/src/Core/RodelChat.Core/ChatClient.cs index 291fb909..2e37ae60 100644 --- a/src/Core/RodelChat.Core/ChatClient.cs +++ b/src/Core/RodelChat.Core/ChatClient.cs @@ -5,6 +5,8 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Agents; +using Microsoft.SemanticKernel.Agents.Chat; using Microsoft.SemanticKernel.ChatCompletion; using RodelAgent.Models.Abstractions; using RodelChat.Interfaces.Client; @@ -27,18 +29,26 @@ public ChatClient( ILogger logger) { Sessions = new List(); + Groups = new List(); _logger = logger; _providerFactory = providerFactory; _parameterFactory = parameterFactory; } /// - public void LoadSessions(List sessions) + public void LoadChatSessions(List sessions) { Sessions.Clear(); Sessions.AddRange(sessions); } + /// + public void LoadGroupSessions(List groups) + { + Groups.Clear(); + Groups.AddRange(groups); + } + /// public List GetPredefinedModels(ProviderType type) { @@ -103,6 +113,18 @@ public ChatSession CreateSession(ChatSessionPreset preset) return session; } + /// + public ChatGroup CreateSession(ChatGroupPreset preset) + { + var id = Guid.NewGuid().ToString("N"); + + var presetJson = JsonSerializer.Serialize(preset); + var newPreset = JsonSerializer.Deserialize(presetJson); + var session = ChatGroup.CreateGroup(id, newPreset); + Groups.Add(session); + return session; + } + /// public List GetModels(ProviderType type) => GetProvider(type).GetModelList(); @@ -167,6 +189,63 @@ public async Task SendMessageAsync( } } + /// + public async Task SendGroupMessageAsync(string groupId, ChatMessage message, Action messageAction = null, List agents = null, CancellationToken cancellationToken = default) + { + var group = Groups.FirstOrDefault(g => g.Id == groupId) + ?? throw new ArgumentException("Group not found."); + var chatAgents = new List(); + foreach (var agentId in group.Agents) + { + var agent = agents.FirstOrDefault(p => p.Id == agentId) + ?? throw new ArgumentException("Agent not found."); + var provider = _providerFactory.GetOrCreateProvider(agent.Provider); + var kernel = FindKernelProvider(agent.Provider, agent.Model) + ?? throw new KernelException($"Parse {agent.Name} failed, because provider config invalid."); + var chatAgent = new ChatCompletionAgent + { + Instructions = agent.SystemInstruction, + ExecutionSettings = provider.ConvertExecutionSettings(agent), + Id = agent.Id, + Kernel = kernel, + Name = EncodeName(agent.Name), + }; + + chatAgents.Add(chatAgent); + } + + var groupChat = new AgentGroupChat(chatAgents.ToArray()) + { + ExecutionSettings = + new() + { + TerminationStrategy = new CustomTerminationStrategy(group.MaxRounds, group.TerminateText), + }, + }; + + if (message.Role == MessageRole.User) + { + group.Messages.Add(message); + } + + foreach (var item in group.Messages) + { + groupChat.AddChatMessage(ConvertToKernelMessage(item)); + } + + await foreach (var content in groupChat.InvokeAsync(cancellationToken)) + { + var assistantName = DecodeName(content.AuthorName); + var agent = agents.FirstOrDefault(p => p.Name == assistantName); + var msg = ChatMessage.CreateAssistantMessage(content.Content); + msg.Time = DateTimeOffset.Now; + msg.Author = assistantName; + msg.AuthorId = agent?.Id ?? string.Empty; + group.Messages.Add(msg); + messageAction?.Invoke(msg); + } + } + /// public void Dispose() { @@ -275,4 +354,22 @@ await Task.Run(() => return !string.IsNullOrEmpty(assemblyPath) && File.Exists(assemblyPath) ? Assembly.LoadFile(assemblyPath) : default; } + + private sealed class CustomTerminationStrategy : TerminationStrategy + { + private readonly IList? _terminateText; + + public CustomTerminationStrategy(int maxRounds, IList? terminateText = default) + { + MaximumIterations = maxRounds; + _terminateText = terminateText; + } + + protected override Task ShouldAgentTerminateAsync(Agent agent, IReadOnlyList history, CancellationToken cancellationToken) + { + return _terminateText is null || _terminateText.Count == 0 + ? Task.FromResult(false) + : Task.FromResult(_terminateText.Any(p => history[history.Count - 1].Content?.Contains(p, StringComparison.InvariantCultureIgnoreCase) ?? false)); + } + } } diff --git a/src/Core/RodelChat.Core/Providers/AnthropicProvider.cs b/src/Core/RodelChat.Core/Providers/AnthropicProvider.cs index 7df7b2bc..e9237684 100644 --- a/src/Core/RodelChat.Core/Providers/AnthropicProvider.cs +++ b/src/Core/RodelChat.Core/Providers/AnthropicProvider.cs @@ -38,7 +38,7 @@ public AnthropicProvider(AnthropicClientConfig config) } /// - public override PromptExecutionSettings ConvertExecutionSettings(ChatSession sessionData) + public override PromptExecutionSettings ConvertExecutionSettings(ChatSessionPreset sessionData) => new AnthropicPromptExecutionSettings { MaxTokens = sessionData.Parameters.GetValueOrDefault(nameof(AnthropicChatParameters.MaxTokens)), diff --git a/src/Core/RodelChat.Core/Providers/GeminiProvider.cs b/src/Core/RodelChat.Core/Providers/GeminiProvider.cs index 3a761da8..62ebf204 100644 --- a/src/Core/RodelChat.Core/Providers/GeminiProvider.cs +++ b/src/Core/RodelChat.Core/Providers/GeminiProvider.cs @@ -38,7 +38,7 @@ public GeminiProvider(GeminiClientConfig config) } /// - public override PromptExecutionSettings ConvertExecutionSettings(ChatSession sessionData) + public override PromptExecutionSettings ConvertExecutionSettings(ChatSessionPreset sessionData) => new GeminiPromptExecutionSettings { TopP = sessionData.Parameters.GetValueOrDefault(nameof(GeminiChatParameters.TopP)), diff --git a/src/Core/RodelChat.Core/Providers/HunYuanProvider.cs b/src/Core/RodelChat.Core/Providers/HunYuanProvider.cs index b2e6dd1a..aed3c804 100644 --- a/src/Core/RodelChat.Core/Providers/HunYuanProvider.cs +++ b/src/Core/RodelChat.Core/Providers/HunYuanProvider.cs @@ -44,7 +44,7 @@ public HunYuanProvider(HunYuanClientConfig config) } /// - public override PromptExecutionSettings ConvertExecutionSettings(ChatSession sessionData) + public override PromptExecutionSettings ConvertExecutionSettings(ChatSessionPreset sessionData) => new HunYuanPromptExecutionSettings { Temperature = sessionData.Parameters.GetValueOrDefault(nameof(HunYuanChatParameters.Temperature)), diff --git a/src/Core/RodelChat.Core/Providers/ProviderBase.cs b/src/Core/RodelChat.Core/Providers/ProviderBase.cs index 1ebfc43a..65cfc0f6 100644 --- a/src/Core/RodelChat.Core/Providers/ProviderBase.cs +++ b/src/Core/RodelChat.Core/Providers/ProviderBase.cs @@ -134,7 +134,7 @@ public void Release() /// /// 会话. /// 执行设置. - public virtual PromptExecutionSettings ConvertExecutionSettings(ChatSession sessionData) + public virtual PromptExecutionSettings ConvertExecutionSettings(ChatSessionPreset sessionData) => new OpenAIPromptExecutionSettings { PresencePenalty = sessionData.Parameters.GetValueOrDefault(nameof(OpenAIChatParameters.FrequencyPenalty)), diff --git a/src/Core/RodelChat.Core/Providers/QianFanProvider.cs b/src/Core/RodelChat.Core/Providers/QianFanProvider.cs index 53c1501f..a443b201 100644 --- a/src/Core/RodelChat.Core/Providers/QianFanProvider.cs +++ b/src/Core/RodelChat.Core/Providers/QianFanProvider.cs @@ -44,7 +44,7 @@ public QianFanProvider(QianFanClientConfig config) } /// - public override PromptExecutionSettings ConvertExecutionSettings(ChatSession sessionData) + public override PromptExecutionSettings ConvertExecutionSettings(ChatSessionPreset sessionData) => new QianFanPromptExecutionSettings { MaxTokens = sessionData.Parameters.GetValueOrDefault(nameof(QianFanChatParameters.MaxOutputTokens)), diff --git a/src/Core/RodelChat.Core/Providers/SparkDeskProvider.cs b/src/Core/RodelChat.Core/Providers/SparkDeskProvider.cs index b4b93d38..05380de7 100644 --- a/src/Core/RodelChat.Core/Providers/SparkDeskProvider.cs +++ b/src/Core/RodelChat.Core/Providers/SparkDeskProvider.cs @@ -49,7 +49,7 @@ public SparkDeskProvider(SparkDeskClientConfig config) } /// - public override PromptExecutionSettings ConvertExecutionSettings(ChatSession sessionData) + public override PromptExecutionSettings ConvertExecutionSettings(ChatSessionPreset sessionData) => new SparkDeskPromptExecutionSettings { MaxTokens = sessionData.Parameters.GetValueOrDefault(nameof(SparkDeskChatParameters.MaxTokens)), diff --git a/src/Core/RodelChat.Core/Providers/ZhiPuProvider.cs b/src/Core/RodelChat.Core/Providers/ZhiPuProvider.cs index 1137c20f..9c278a1a 100644 --- a/src/Core/RodelChat.Core/Providers/ZhiPuProvider.cs +++ b/src/Core/RodelChat.Core/Providers/ZhiPuProvider.cs @@ -38,7 +38,7 @@ public ZhiPuProvider(ZhiPuClientConfig config) } /// - public override PromptExecutionSettings ConvertExecutionSettings(ChatSession sessionData) + public override PromptExecutionSettings ConvertExecutionSettings(ChatSessionPreset sessionData) => new OpenAIPromptExecutionSettings { MaxTokens = sessionData.Parameters.GetValueOrDefault(nameof(ZhiPuChatParameters.MaxTokens)), diff --git a/src/Core/RodelChat.Core/RodelChat.Core.csproj b/src/Core/RodelChat.Core/RodelChat.Core.csproj index 5763a0fc..aadc0239 100644 --- a/src/Core/RodelChat.Core/RodelChat.Core.csproj +++ b/src/Core/RodelChat.Core/RodelChat.Core.csproj @@ -4,9 +4,11 @@ net8.0 enable enable + SKEXP0110;SKEXP0010;SKEXP0001 + diff --git a/src/Core/RodelChat.Interfaces/Client/IChatClient.cs b/src/Core/RodelChat.Interfaces/Client/IChatClient.cs index cc370b82..c02ce830 100644 --- a/src/Core/RodelChat.Interfaces/Client/IChatClient.cs +++ b/src/Core/RodelChat.Interfaces/Client/IChatClient.cs @@ -21,7 +21,13 @@ public interface IChatClient : IDisposable /// 加载会话列表. /// /// 会话列表. - void LoadSessions(List sessions); + void LoadChatSessions(List sessions); + + /// + /// 加载群组会话列表. + /// + /// 群组会话列表. + void LoadGroupSessions(List groups); /// /// 获取预定义模型. @@ -43,6 +49,13 @@ public interface IChatClient : IDisposable /// 会话信息. ChatSession CreateSession(ChatSessionPreset preset); + /// + /// 创建新群组会话. + /// + /// 群组预设. + /// 群组会话. + ChatGroup CreateSession(ChatGroupPreset preset); + /// /// 获取模型列表. /// @@ -68,6 +81,22 @@ Task SendMessageAsync( List plugins = null, CancellationToken cancellationToken = default); + /// + /// 发送群组消息. + /// + /// 群组标识符. + /// 群组消息. + /// 消息生成事件. + /// 助理列表. + /// 终止令牌. + /// . + Task SendGroupMessageAsync( + string groupId, + ChatMessage message, + Action messageAction = default, + List agents = default, + CancellationToken cancellationToken = default); + /// /// 从 DLL 中检索插件. /// diff --git a/src/Core/RodelChat.Interfaces/Client/IProvider.cs b/src/Core/RodelChat.Interfaces/Client/IProvider.cs index 50841bd5..8f8bf4af 100644 --- a/src/Core/RodelChat.Interfaces/Client/IProvider.cs +++ b/src/Core/RodelChat.Interfaces/Client/IProvider.cs @@ -36,7 +36,7 @@ public interface IProvider /// /// 会话. /// 执行设置. - PromptExecutionSettings ConvertExecutionSettings(ChatSession sessionData); + PromptExecutionSettings ConvertExecutionSettings(ChatSessionPreset sessionData); /// /// 获取模型列表. diff --git a/src/Core/RodelChat.Models/Client/ChatClientConfiguration.cs b/src/Core/RodelChat.Models/Client/ChatClientConfiguration.cs index 214c38d8..eac8a605 100644 --- a/src/Core/RodelChat.Models/Client/ChatClientConfiguration.cs +++ b/src/Core/RodelChat.Models/Client/ChatClientConfiguration.cs @@ -124,12 +124,6 @@ public sealed class ChatClientConfiguration /// [JsonPropertyName("silicon_flow")] public SiliconFlowClientConfig? SiliconFlow { get; set; } - - /// - /// 本地模型配置. - /// - [JsonPropertyName("local")] - public LocalModelConfig? Local { get; set; } } /// @@ -319,13 +313,6 @@ public override bool IsValid() => IsCustomModelNotEmpty() && !string.IsNullOrEmpty(Endpoint); } -/// -/// 本地模型配置. -/// -public sealed class LocalModelConfig : ConfigBase -{ -} - /// /// 配置基类. /// diff --git a/src/Core/RodelChat.Models/Client/ChatGroup.cs b/src/Core/RodelChat.Models/Client/ChatGroup.cs new file mode 100644 index 00000000..3495a404 --- /dev/null +++ b/src/Core/RodelChat.Models/Client/ChatGroup.cs @@ -0,0 +1,51 @@ +// Copyright (c) Rodel. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace RodelChat.Models.Client; + +/// +/// 聊天群组. +/// +public sealed class ChatGroup : ChatGroupPreset +{ + /// + /// 群组标题. + /// + [JsonPropertyName("title")] + public string Title { get; set; } + + /// + /// 群组预设标识. + /// + [JsonPropertyName("preset_id")] + public string PresetId { get; set; } + + /// + /// 获取或设置历史记录. + /// + [JsonPropertyName("messages")] + public List? Messages { get; set; } + + /// + /// 创建会话. + /// + /// 标识符. + /// 预设. + /// 群组会话. + public static ChatGroup CreateGroup(string id, ChatGroupPreset preset) + { + return new ChatGroup + { + Id = id, + PresetId = preset.Id, + Messages = new List(), + Agents = preset.Agents, + Emoji = preset.Emoji, + Name = preset.Name, + MaxRounds = preset.MaxRounds, + TerminateText = preset.TerminateText, + }; + } +} diff --git a/src/Core/RodelChat.Models/Client/ChatGroupPreset.cs b/src/Core/RodelChat.Models/Client/ChatGroupPreset.cs new file mode 100644 index 00000000..24d25ef1 --- /dev/null +++ b/src/Core/RodelChat.Models/Client/ChatGroupPreset.cs @@ -0,0 +1,73 @@ +// Copyright (c) Rodel. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace RodelChat.Models.Client; + +/// +/// 聊天群组预设. +/// +public class ChatGroupPreset +{ + /// + /// 会话标识符. + /// + [JsonPropertyName("id")] + public string Id { get; set; } + + /// + /// 预设名称. + /// + [JsonPropertyName("name")] + [JsonRequired] + public string Name { get; set; } = null!; + + /// + /// 群组成员. + /// + [JsonPropertyName("agents")] + public IList? Agents { get; set; } + + /// + /// 表情头像. + /// + [JsonPropertyName("emoji")] + public string? Emoji { get; set; } + + /// + /// 最大会话轮次. + /// + [JsonPropertyName("max_rounds")] + public int MaxRounds { get; set; } + + /// + /// 终结文本. + /// + [JsonPropertyName("terminate_text")] + public IList? TerminateText { get; set; } + + /// + /// 克隆当前实例. + /// + /// . + public ChatGroupPreset Clone() + { + return new ChatGroupPreset + { + Id = Id, + Name = Name, + Agents = Agents, + Emoji = Emoji, + MaxRounds = MaxRounds, + TerminateText = TerminateText, + }; + } + + /// + public override bool Equals(object? obj) => obj is ChatGroupPreset preset && Id == preset.Id; + + /// + public override int GetHashCode() => HashCode.Combine(Id); +} diff --git a/src/Core/RodelChat.Models/Client/ChatMessage.cs b/src/Core/RodelChat.Models/Client/ChatMessage.cs index 2cb151ac..00347543 100644 --- a/src/Core/RodelChat.Models/Client/ChatMessage.cs +++ b/src/Core/RodelChat.Models/Client/ChatMessage.cs @@ -20,6 +20,18 @@ public sealed class ChatMessage [JsonPropertyName("role")] public MessageRole Role { get; set; } + /// + /// 发送者名称. + /// + [JsonPropertyName("author")] + public string? Author { get; set; } + + /// + /// 发送者 ID. + /// + [JsonPropertyName("author_id")] + public string? AuthorId { get; set; } + /// /// 消息内容. /// diff --git a/src/Core/RodelChat.Models/Client/ChatSession.cs b/src/Core/RodelChat.Models/Client/ChatSession.cs index b853a211..a30cfcef 100644 --- a/src/Core/RodelChat.Models/Client/ChatSession.cs +++ b/src/Core/RodelChat.Models/Client/ChatSession.cs @@ -61,6 +61,8 @@ public static ChatSession CreateSession(string newId, ChatSessionPreset preset) SystemInstruction = preset.SystemInstruction, StopSequences = preset.StopSequences, FilterCharacters = preset.FilterCharacters, + Emoji = preset.Emoji, + Plugins = preset.Plugins, }; } } diff --git a/src/Desktop/RodelAgent.UI.Models/Constants/ChatGroupPanelType.cs b/src/Desktop/RodelAgent.UI.Models/Constants/ChatGroupPanelType.cs new file mode 100644 index 00000000..201f1248 --- /dev/null +++ b/src/Desktop/RodelAgent.UI.Models/Constants/ChatGroupPanelType.cs @@ -0,0 +1,19 @@ +// Copyright (c) Rodel. All rights reserved. + +namespace RodelAgent.UI.Models.Constants; + +/// +/// 聊天群面板类型. +/// +public enum ChatGroupPanelType +{ + /// + /// 助理. + /// + Agents, + + /// + /// 群组选项. + /// + GroupOptions, +} diff --git a/src/Desktop/RodelAgent.UI.Models/Constants/SettingNames.cs b/src/Desktop/RodelAgent.UI.Models/Constants/SettingNames.cs index 8000ca51..ac7d74c0 100644 --- a/src/Desktop/RodelAgent.UI.Models/Constants/SettingNames.cs +++ b/src/Desktop/RodelAgent.UI.Models/Constants/SettingNames.cs @@ -39,4 +39,6 @@ public enum SettingNames AudioHistoryColumnWidth, AudioServicePageIsEnterSend, IsMigrating, + LastSelectedGroup, + ChatGroupPanelType, } diff --git a/src/Desktop/RodelAgent.UI/App.xaml b/src/Desktop/RodelAgent.UI/App.xaml index 03196f08..864d468c 100644 --- a/src/Desktop/RodelAgent.UI/App.xaml +++ b/src/Desktop/RodelAgent.UI/App.xaml @@ -17,6 +17,9 @@ + + + diff --git a/src/Desktop/RodelAgent.UI/Controls/Base/PresetAvatar.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Base/PresetAvatar.xaml.cs index c06169bd..341eb1c6 100644 --- a/src/Desktop/RodelAgent.UI/Controls/Base/PresetAvatar.xaml.cs +++ b/src/Desktop/RodelAgent.UI/Controls/Base/PresetAvatar.xaml.cs @@ -36,6 +36,11 @@ public PresetAvatar() Unloaded += OnUnloaded; } + /// + /// 是否为聊天预设. + /// + public bool IsChatPreset { get; set; } = true; + /// /// 预设 ID. /// @@ -84,13 +89,25 @@ private async void CheckAvatarAsync() return; } - var preset = await GlobalDependencies.ServiceProvider.GetRequiredService().GetChatSessionPresetByIdAsync(PresetId); - if (!string.IsNullOrEmpty(preset.Emoji)) + var emojiText = string.Empty; + var storageService = GlobalDependencies.ServiceProvider.GetRequiredService(); + if (IsChatPreset) + { + var preset = await storageService.GetChatSessionPresetByIdAsync(PresetId); + emojiText = preset?.Emoji; + } + else + { + var preset = await storageService.GetChatGroupPresetByIdAsync(PresetId); + emojiText = preset?.Emoji; + } + + if (!string.IsNullOrEmpty(emojiText)) { AgentAvatar.Visibility = Visibility.Collapsed; DefaultIcon.Visibility = Visibility.Collapsed; EmojiAvatar.Visibility = Visibility.Visible; - var emoji = EmojiStatics.GetEmojis().FirstOrDefault(x => x.Unicode == preset.Emoji); + var emoji = EmojiStatics.GetEmojis().FirstOrDefault(x => x.Unicode == emojiText); EmojiAvatar.Text = emoji?.ToEmoji(); } else diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/AgentsSection.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/AgentsSection.xaml.cs index 1fcedcf3..71707a2d 100644 --- a/src/Desktop/RodelAgent.UI/Controls/Chat/AgentsSection.xaml.cs +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/AgentsSection.xaml.cs @@ -3,7 +3,7 @@ namespace RodelAgent.UI.Controls.Chat; /// -/// 本地模型区. +/// 助理区. /// public sealed partial class AgentsSection : ChatServicePageControlBase { diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupControlBase.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupControlBase.cs new file mode 100644 index 00000000..393397e9 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupControlBase.cs @@ -0,0 +1,12 @@ +// Copyright (c) Rodel. All rights reserved. + +using RodelAgent.UI.ViewModels.Components; + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 群组会话控件基类. +/// +public abstract class ChatGroupControlBase : ReactiveUserControl +{ +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHeader.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHeader.xaml new file mode 100644 index 00000000..187a11a2 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHeader.xaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHeader.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHeader.xaml.cs new file mode 100644 index 00000000..8b4c7e60 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHeader.xaml.cs @@ -0,0 +1,51 @@ +// Copyright (c) Rodel. All rights reserved. + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 群组会话头部. +/// +public sealed partial class ChatGroupHeader : ChatGroupControlBase +{ + /// + /// Initializes a new instance of the class. + /// + public ChatGroupHeader() + { + InitializeComponent(); + ShareButton.Visibility = GlobalFeatureSwitcher.IsChatShareEnabled ? Visibility.Visible : Visibility.Collapsed; + } + + private void ShowRename() + { + TitleContainer.Visibility = Visibility.Collapsed; + RenameBox.Visibility = Visibility.Visible; + RenameBox.Text = ViewModel.Data.Title ?? string.Empty; + RenameBox.Focus(FocusState.Programmatic); + } + + private void HideRenameAndSave() + { + TitleContainer.Visibility = Visibility.Visible; + RenameBox.Visibility = Visibility.Collapsed; + if (RenameBox.Text != (ViewModel.Data.Title ?? string.Empty)) + { + ViewModel.ChangeTitleCommand.Execute(RenameBox.Text); + } + } + + private void OnRenameBoxLostFocus(object sender, RoutedEventArgs e) + => HideRenameAndSave(); + + private void OnRenameBoxPreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == Windows.System.VirtualKey.Enter) + { + HideRenameAndSave(); + e.Handled = true; + } + } + + private void OnTitleTapped(object sender, TappedRoutedEventArgs e) + => ShowRename(); +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHistory.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHistory.xaml new file mode 100644 index 00000000..3ad006c8 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHistory.xaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHistory.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHistory.xaml.cs new file mode 100644 index 00000000..50810e28 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupHistory.xaml.cs @@ -0,0 +1,51 @@ +// Copyright (c) Rodel. All rights reserved. + +using RodelAgent.UI.ViewModels.Components; + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 聊天会话历史. +/// +public sealed partial class ChatGroupHistory : ChatGroupControlBase +{ + /// + /// Initializes a new instance of the class. + /// + public ChatGroupHistory() + { + InitializeComponent(); + Unloaded += OnUnloaded; + } + + /// + protected override void OnViewModelChanged(DependencyPropertyChangedEventArgs e) + { + if (e.OldValue is ChatGroupViewModel oldVm) + { + oldVm.RequestScrollToBottom -= OnRequestScrollToBottomAsync; + } + + if (e.NewValue is ChatGroupViewModel newVm) + { + newVm.RequestScrollToBottom += OnRequestScrollToBottomAsync; + } + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (ViewModel is not null) + { + ViewModel.RequestScrollToBottom -= OnRequestScrollToBottomAsync; + } + } + + private async void OnRequestScrollToBottomAsync(object sender, EventArgs e) + { + if (MessageViewer is not null) + { + await Task.Delay(200); + MessageViewer.ChangeView(0, MessageViewer.ScrollableHeight + MessageViewer.ActualHeight + MessageViewer.VerticalOffset, default); + } + } +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupInput.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupInput.xaml new file mode 100644 index 00000000..acd27501 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupInput.xaml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupInput.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupInput.xaml.cs new file mode 100644 index 00000000..3e9f9d7c --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupInput.xaml.cs @@ -0,0 +1,104 @@ +// Copyright (c) Rodel. All rights reserved. + +using Microsoft.UI.Input; +using RodelAgent.UI.ViewModels.Components; +using Windows.System; +using Windows.UI.Core; + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 群组会话输入. +/// +public sealed partial class ChatGroupInput : ChatGroupControlBase +{ + /// + /// Initializes a new instance of the class. + /// + public ChatGroupInput() + { + InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + /// + protected override void OnViewModelChanged(DependencyPropertyChangedEventArgs e) + { + if (e.OldValue is ChatGroupViewModel oldVm) + { + oldVm.RequestFocusInput -= OnRequestFocusInput; + } + + if (e.NewValue is ChatGroupViewModel newVm) + { + newVm.RequestFocusInput += OnRequestFocusInput; + } + + CheckEnterSendItem(); + } + + private void OnRequestFocusInput(object sender, EventArgs e) + { + InputBox.Focus(FocusState.Programmatic); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + => CheckEnterSendItem(); + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (ViewModel is not null) + { + ViewModel.RequestFocusInput -= OnRequestFocusInput; + } + } + + private async void OnInputBoxPreviewKeyDownAsync(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Enter) + { + var shiftState = InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift); + var isShiftDown = shiftState == CoreVirtualKeyStates.Down || shiftState == (CoreVirtualKeyStates.Down | CoreVirtualKeyStates.Locked); + var ctrlState = InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Control); + var isCtrlDown = ctrlState == CoreVirtualKeyStates.Down || ctrlState == (CoreVirtualKeyStates.Down | CoreVirtualKeyStates.Locked); + + if ((ViewModel.IsEnterSend && !isShiftDown) + || (!ViewModel.IsEnterSend && isCtrlDown)) + { + e.Handled = true; + await ViewModel.SendCommand.ExecuteAsync(default); + } + } + } + + private void OnCtrlEnterSendItemClick(object sender, RoutedEventArgs e) + { + ViewModel.IsEnterSend = false; + CheckEnterSendItem(); + } + + private void OnEnterSendItemClick(object sender, RoutedEventArgs e) + { + ViewModel.IsEnterSend = true; + CheckEnterSendItem(); + } + + private void CheckEnterSendItem() + { + if (EnterSendItem != null && ViewModel != null) + { + EnterSendItem.IsChecked = ViewModel.IsEnterSend; + CtrlEnterSendItem.IsChecked = !ViewModel.IsEnterSend; + } + } + + private void OnCleanMessageButtonClick(object sender, RoutedEventArgs e) + => CleanMessageTip.IsOpen = true; + + private void OnClearMessageActionButtonClick(TeachingTip sender, object args) + { + ViewModel.ClearMessageCommand.Execute(default); + sender.IsOpen = false; + } +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupListPanel.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupListPanel.xaml new file mode 100644 index 00000000..b3a4a4ed --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupListPanel.xaml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupListPanel.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupListPanel.xaml.cs new file mode 100644 index 00000000..a2713af4 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupListPanel.xaml.cs @@ -0,0 +1,32 @@ +// Copyright (c) Rodel. All rights reserved. + +using RodelAgent.UI.ViewModels.Components; + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 群组会话列表面板. +/// +public sealed partial class ChatGroupListPanel : ChatServicePageControlBase +{ + /// + /// Initializes a new instance of the class. + /// + public ChatGroupListPanel() => InitializeComponent(); + + private void OnItemClick(object sender, ViewModels.Components.ChatGroupViewModel e) + => ViewModel.SetSelectedGroupSessionCommand.Execute(e); + + private async void OnRenameItemClickAsync(object sender, RoutedEventArgs e) + { + var context = (sender as FrameworkElement)?.DataContext as ChatGroupViewModel; + var dialog = new SessionRenameDialog(context); + await dialog.ShowAsync(); + } + + private void OnDeleteItemClick(object sender, RoutedEventArgs e) + { + var context = (sender as FrameworkElement)?.DataContext as ChatGroupViewModel; + ViewModel.RemoveGroupCommand.Execute(context); + } +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupPanel.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupPanel.xaml new file mode 100644 index 00000000..05f0f501 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupPanel.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupPanel.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupPanel.xaml.cs new file mode 100644 index 00000000..3986245a --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatGroupPanel.xaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Rodel. All rights reserved. + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 群组会话面板. +/// +public sealed partial class ChatGroupPanel : ChatGroupControlBase +{ + /// + /// Initializes a new instance of the class. + /// + public ChatGroupPanel() => InitializeComponent(); +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatMessageItemControl.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatMessageItemControl.xaml index eb8cad62..d8f8d123 100644 --- a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatMessageItemControl.xaml +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatMessageItemControl.xaml @@ -40,8 +40,24 @@ + + + + + - + Orientation="Horizontal"> + + + + + + - + () + .ShowTip(StringNames.MustFillRequireFields, InfoType.Warning); + btn.IsEnabled = true; + return; + } + try { await ModelPanel.SaveAvatarAsync(); diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatServiceHeader.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatServiceHeader.xaml index 4bbb8a28..dbea701f 100644 --- a/src/Desktop/RodelAgent.UI/Controls/Chat/ChatServiceHeader.xaml +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/ChatServiceHeader.xaml @@ -62,6 +62,15 @@ Symbol="MoreHorizontal" /> + + + + + - + Visibility="{x:Bind ViewModel.IsChatHistorySessionsEmpty, Mode=OneWay, Converter={StaticResource BoolToVisibilityReverseConverter}}"> + + Visibility="{x:Bind ViewModel.IsChatHistorySessionsEmpty, Mode=OneWay}"> + + + + + + + + + + + + + + + + + diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/GroupAgentsPanel.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupAgentsPanel.xaml.cs new file mode 100644 index 00000000..27e61239 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupAgentsPanel.xaml.cs @@ -0,0 +1,14 @@ +// Copyright (c) Rodel. All rights reserved. + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 聊天群助理面板. +/// +public sealed partial class GroupAgentsPanel : ChatGroupControlBase +{ + /// + /// Initializes a new instance of the class. + /// + public GroupAgentsPanel() => InitializeComponent(); +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/GroupOptionsPanel.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupOptionsPanel.xaml new file mode 100644 index 00000000..d28efe5c --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupOptionsPanel.xaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/GroupOptionsPanel.xaml.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupOptionsPanel.xaml.cs new file mode 100644 index 00000000..e8692c25 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupOptionsPanel.xaml.cs @@ -0,0 +1,31 @@ +// Copyright (c) Rodel. All rights reserved. + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 聊天群选项面板. +/// +public sealed partial class GroupOptionsPanel : ChatGroupControlBase +{ + /// + /// Initializes a new instance of the class. + /// + public GroupOptionsPanel() + { + InitializeComponent(); + } + + private void OnMaxRoundsChanged(object sender, RangeBaseValueChangedEventArgs e) + { + if (e.NewValue < 1 || ViewModel is null) + { + return; + } + + ViewModel.MaxRounds = (int)e.NewValue; + ViewModel.CheckMaxRoundsCommand.Execute(default); + } + + private void OnTerminateTextSubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + => ViewModel.CheckMaxRoundsCommand.Execute(default); +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/GroupPresetControlBase.cs b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupPresetControlBase.cs new file mode 100644 index 00000000..c6c5035c --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupPresetControlBase.cs @@ -0,0 +1,16 @@ +// Copyright (c) Rodel. All rights reserved. + +using RodelAgent.UI.ViewModels.Components; + +namespace RodelAgent.UI.Controls.Chat; + +/// +/// 群组预设控件基类. +/// +public abstract class GroupPresetControlBase : ReactiveUserControl +{ + /// + /// Initializes a new instance of the class. + /// + protected GroupPresetControlBase() => ViewModel = ServiceProvider.GetRequiredService(); +} diff --git a/src/Desktop/RodelAgent.UI/Controls/Chat/GroupPresetSettingsDialog.xaml b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupPresetSettingsDialog.xaml new file mode 100644 index 00000000..e69bba50 --- /dev/null +++ b/src/Desktop/RodelAgent.UI/Controls/Chat/GroupPresetSettingsDialog.xaml @@ -0,0 +1,82 @@ + + + + + 320 + 800 + 184 + 1999 + 0 + + + + + + + + + + + + + + + + + + + + + + + +