diff --git a/OllamaStudy.Core/OllamaStudy.Core.csproj b/OllamaStudy.Core/OllamaStudy.Core.csproj index 722337e..c9de1bd 100644 --- a/OllamaStudy.Core/OllamaStudy.Core.csproj +++ b/OllamaStudy.Core/OllamaStudy.Core.csproj @@ -7,17 +7,17 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/OllamaStudy.UseExtensionsAI/Assets/audio_french.wav b/OllamaStudy.UseExtensionsAI/Assets/audio_french.wav new file mode 100644 index 0000000..847f346 Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/audio_french.wav differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/audio_houseplant_care.mp3 b/OllamaStudy.UseExtensionsAI/Assets/audio_houseplant_care.mp3 new file mode 100644 index 0000000..8a2f614 Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/audio_houseplant_care.mp3 differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/chengcheng.mp3 b/OllamaStudy.UseExtensionsAI/Assets/chengcheng.mp3 new file mode 100644 index 0000000..b97eede Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/chengcheng.mp3 differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/dongdong.mp3 b/OllamaStudy.UseExtensionsAI/Assets/dongdong.mp3 new file mode 100644 index 0000000..a4adfef Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/dongdong.mp3 differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/images_apple.png b/OllamaStudy.UseExtensionsAI/Assets/images_apple.png new file mode 100644 index 0000000..4e04c26 Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/images_apple.png differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/images_dog_and_cat.png b/OllamaStudy.UseExtensionsAI/Assets/images_dog_and_cat.png new file mode 100644 index 0000000..063f3e4 Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/images_dog_and_cat.png differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/images_flower_vase.png b/OllamaStudy.UseExtensionsAI/Assets/images_flower_vase.png new file mode 100644 index 0000000..b2647dd Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/images_flower_vase.png differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/images_flower_vase_with_mask.png b/OllamaStudy.UseExtensionsAI/Assets/images_flower_vase_with_mask.png new file mode 100644 index 0000000..cb19542 Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/images_flower_vase_with_mask.png differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/images_orange.png b/OllamaStudy.UseExtensionsAI/Assets/images_orange.png new file mode 100644 index 0000000..30e8e89 Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/images_orange.png differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/realtime_whats_the_weather_pcm16_24khz_mono.wav b/OllamaStudy.UseExtensionsAI/Assets/realtime_whats_the_weather_pcm16_24khz_mono.wav new file mode 100644 index 0000000..399cd5a Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/realtime_whats_the_weather_pcm16_24khz_mono.wav differ diff --git a/OllamaStudy.UseExtensionsAI/Assets/yuxia.mp3 b/OllamaStudy.UseExtensionsAI/Assets/yuxia.mp3 new file mode 100644 index 0000000..5b2cf25 Binary files /dev/null and b/OllamaStudy.UseExtensionsAI/Assets/yuxia.mp3 differ diff --git a/OllamaStudy.UseExtensionsAI/OllamaStudy.UseExtensionsAI.csproj b/OllamaStudy.UseExtensionsAI/OllamaStudy.UseExtensionsAI.csproj index bc3235c..7c5be87 100644 --- a/OllamaStudy.UseExtensionsAI/OllamaStudy.UseExtensionsAI.csproj +++ b/OllamaStudy.UseExtensionsAI/OllamaStudy.UseExtensionsAI.csproj @@ -19,8 +19,9 @@ - - + + + @@ -38,4 +39,40 @@ + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/OllamaStudy.UseExtensionsAI/OpenAISdkTest.cs b/OllamaStudy.UseExtensionsAI/OpenAISdkTest.cs index d46b248..83858f4 100644 --- a/OllamaStudy.UseExtensionsAI/OpenAISdkTest.cs +++ b/OllamaStudy.UseExtensionsAI/OpenAISdkTest.cs @@ -1,4 +1,10 @@ -namespace OllamaStudy.UseExtensionsAI; +using System.Net.Sockets; +using System.Threading.Tasks; + +using OpenAI; +using OpenAI.Responses; + +namespace OllamaStudy.UseExtensionsAI; /// /// Ollama兼容OpenAI接口,可以直接使用OpenAI的SDK调用 @@ -8,12 +14,22 @@ public class OpenAISdkTest private ITestOutputHelper _output; private IOptionsMonitor _ollamaOptionsMonitor; private OpenAIClient _defaultOpenAIClient; + private ChatClient _singtonChatClient; + + public OpenAISdkTest + ( + ITestOutputHelper outputHelper, + OpenAIClient defaultOpenAIClient, + IOptionsMonitor ollamaOptionsMonitor, - public OpenAISdkTest(ITestOutputHelper outputHelper, OpenAIClient defaultOpenAIClient, IOptionsMonitor ollamaOptionsMonitor) + //使用了FromKeyedServices特性,所以需要使用IKeyedServiceCollection注册服务 + [FromKeyedServices("OpenAIChatClient")]ChatClient singtonChatClient + ) { _output = outputHelper; _defaultOpenAIClient = defaultOpenAIClient; _ollamaOptionsMonitor = ollamaOptionsMonitor; + _singtonChatClient = singtonChatClient; } #region 使用客户端库 @@ -87,31 +103,435 @@ public class OpenAISdkTest #pragma warning restore OPENAI001 } + /// + /// 自定义URL和API密钥 + /// + [Fact] + public void Custom_OpenAIClient_Test() + { + var option = new OpenAIClientOptions() + { + OrganizationId = "TianyiJituan", + ProjectId = "StudyProject", + Endpoint = new Uri("http://localhost:11434/v1") + }; + + //本地Ollama服务,不需要API密钥(随便填写) + var openAIClient = new OpenAIClient(new ApiKeyCredential("nokey"), option); + var chatClient = openAIClient.GetChatClient(_ollamaOptionsMonitor.CurrentValue.Model); + + Assert.NotNull(openAIClient); + Assert.NotNull(chatClient); + } + /// + /// 自定义URL和API密钥 + /// + [Fact] + public void Custom_ChatClient_Test() + { + var option = new OpenAIClientOptions() + { + OrganizationId = "TianyiJituan", + ProjectId = "StudyProject", + UserAgentApplicationId = "StudyAgentApp", + Endpoint = new Uri("http://localhost:11434/v1"), + }; + + var chatClient = new ChatClient(_ollamaOptionsMonitor.CurrentValue.Model,new ApiKeyCredential("nokey"),option); + Assert.NotNull(chatClient); + } + + /// + /// 使用异步API + /// 每个客户端方法在同一客户端类中都有一个异步变体 + /// + [Fact] + public async Task UseAsyncAPI_Test() + { + ChatClient chatClient = _defaultOpenAIClient.GetChatClient(_ollamaOptionsMonitor.CurrentValue.Model); + + ClientResult result = await chatClient.CompleteChatAsync("你好,请问河南的省会是什么?"); + var responseText = result.Value.Content.First().Text; + _output.WriteLine(responseText); + + Assert.NotNull(result); + Assert.Contains("郑州",responseText); + } #endregion #region 如何使用依赖注入 + + /// + /// OpenAI 客户端是线程安全的。可以在DI中安全地注册为单例. + /// 这最大限度地提高了资源效率和 HTTP 连接重用。 + /// + [Fact] + public void Singleton_ChatClient_Test() + { + var result = _singtonChatClient.CompleteChat("你好"); + + var responseText = result.Value.Content.First().Text; + _output.WriteLine(responseText); + Assert.NotNull(result); + } #endregion #region 如何将聊天完成与流式处理一起使用 + /// + /// 使用同步流式处理API,可以立即收到响应,而无需等待模型完成。 + /// + [Fact] + public void Streamimg_ChatClient_Test() + { + CollectionResult result = _singtonChatClient.CompleteChatStreaming("你好"); + + var stringBuilder = new StringBuilder(500); + + foreach (StreamingChatCompletionUpdate completionUpdate in result) + { + if (completionUpdate.ContentUpdate.Count > 0) + { + stringBuilder.Append(completionUpdate.ContentUpdate[0].Text); + } + } + + _output.WriteLine(stringBuilder.ToString()); + } + + /// + /// 使用异步流式处理API + /// + [Fact] + public async Task Singleton_Async_ChatClient_Test() + { + var result = _singtonChatClient.CompleteChatStreamingAsync("你好"); + + var stringBuilder = new StringBuilder(500); + + await foreach (StreamingChatCompletionUpdate completionUpdate in result) + { + if (completionUpdate.ContentUpdate.Count > 0) + { + stringBuilder.Append(completionUpdate.ContentUpdate[0].Text); + } + } + + _output.WriteLine(stringBuilder.ToString()); + } #endregion #region 如何将聊天完成与工具和函数调用一起使用 + /// + /// 调用工具和函数 + /// + [Fact] + public void Use_FunctionCalling_ChatClient_Test() + { + ChatTool getCurrentLocationTool = ChatTool.CreateFunctionTool + ( + functionName: nameof(GetCurrentLocation), + functionDescription: "Get the user's current location" + ); + + ChatTool getCurrentWeatherTool = ChatTool.CreateFunctionTool + ( + functionName: nameof(GetCurrentWeather), + functionDescription: "Get the current weather in a given location", + functionParameters: BinaryData.FromBytes(""" + { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. Boston, MA" + }, + "unit": { + "type": "string", + "enum": [ "celsius", "fahrenheit" ], + "description": "The temperature unit to use. Infer this from the specified location." + } + }, + "required": [ "location" ] + } + """u8.ToArray()) + ); + + List messages = [new UserChatMessage("What's the weather like beijing today?"),]; + + ChatCompletionOptions options = new() + { + Tools = { getCurrentLocationTool, getCurrentWeatherTool }, + }; + + bool requiresAction = false; + + do //实质上是手动调用函数 + { + requiresAction = false; + ChatCompletion completion = _singtonChatClient.CompleteChat(messages, options); + + switch (completion.FinishReason) + { + case OpenAI.Chat.ChatFinishReason.Stop: + { + // Add the assistant message to the conversation history. + messages.Add(new AssistantChatMessage(completion)); + + //输出 + foreach (var message in messages) + { + _output.WriteLine(message.Content.First().Text); + } + + break; + } + + case OpenAI.Chat.ChatFinishReason.ToolCalls: + { + // First, add the assistant message with tool calls to the conversation history. + messages.Add(new AssistantChatMessage(completion)); + + // Then, add a new tool message for each tool call that is resolved. + foreach (ChatToolCall toolCall in completion.ToolCalls) + { + switch (toolCall.FunctionName) + { + case nameof(GetCurrentLocation): + { + string toolResult = GetCurrentLocation(); + messages.Add(new ToolChatMessage(toolCall.Id, toolResult)); + break; + } + + case nameof(GetCurrentWeather): + { + // The arguments that the model wants to use to call the function are specified as a + // stringified JSON object based on the schema defined in the tool definition. Note that + // the model may hallucinate arguments too. Consequently, it is important to do the + // appropriate parsing and validation before calling the function. + using JsonDocument argumentsJson = JsonDocument.Parse(toolCall.FunctionArguments); + bool hasLocation = argumentsJson.RootElement.TryGetProperty("location", out JsonElement location); + bool hasUnit = argumentsJson.RootElement.TryGetProperty("unit", out JsonElement unit); + + if (!hasLocation) + { + throw new ArgumentNullException(nameof(location), "The location argument is required."); + } + + string toolResult = hasUnit + ? GetCurrentWeather(location.GetString() ?? "", unit.GetString() ?? "") + : GetCurrentWeather(location.GetString() ?? ""); + messages.Add(new ToolChatMessage(toolCall.Id, toolResult)); + break; + } + + default: + { + // Handle other unexpected calls. + throw new NotImplementedException(); + } + } + } + + requiresAction = true; + break; + } + + case OpenAI.Chat.ChatFinishReason.Length: + throw new NotImplementedException("Incomplete model output due to MaxTokens parameter or token limit exceeded."); + + case OpenAI.Chat.ChatFinishReason.ContentFilter: + throw new NotImplementedException("Omitted content due to a content filter flag."); + + case OpenAI.Chat.ChatFinishReason.FunctionCall: + throw new NotImplementedException("Deprecated in favor of tool calls."); + + default: + throw new NotImplementedException(completion.FinishReason.ToString()); + } + } while (requiresAction); + } #endregion #region 如何将聊天完成与结构化输出一起使用 + [Fact] + public void StructuredOutputs_ChatClient_Test() + { + List messages =[new UserChatMessage("How can I solve 8x + 7 = -23?"),]; + + ChatCompletionOptions options = new() + { + ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( + jsonSchemaFormatName: "math_reasoning", + jsonSchema: BinaryData.FromBytes(""" + { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "explanation": { "type": "string" }, + "output": { "type": "string" } + }, + "required": ["explanation", "output"], + "additionalProperties": false + } + }, + "final_answer": { "type": "string" } + }, + "required": ["steps", "final_answer"], + "additionalProperties": false + } + """u8.ToArray()), + jsonSchemaIsStrict: true) + }; + + ChatCompletion completion = _singtonChatClient.CompleteChat(messages, options); + + using JsonDocument structuredJson = JsonDocument.Parse(completion.Content[0].Text); + + _output.WriteLine($"Final answer: {structuredJson.RootElement.GetProperty("final_answer")}"); + _output.WriteLine("Reasoning steps:"); + + foreach (JsonElement stepElement in structuredJson.RootElement.GetProperty("steps").EnumerateArray()) + { + _output.WriteLine($" - Explanation: {stepElement.GetProperty("explanation")}"); + _output.WriteLine($" Output: {stepElement.GetProperty("output")}"); + } + + } #endregion #region 如何将聊天完成与音频一起使用 + /// + /// 生成语音 + /// + //[Fact] + [Fact(Skip ="因本地Ollama测试环境,不支持OpenAI音频接口,忽略测试")] + //[Fact] + public void GenerateSpeech_AudioClient_Test() + { + var aiClientOption = new OpenAIClientOptions() + { + Endpoint = new Uri("https://sg.uiuiapi.com/v1") + }; + + AudioClient client = new("tts-1-1106", new ApiKeyCredential("sk-4azuOUkbzNGP22pQkND8ad1vZl7ladwBQyqGKlWWZyxYgX1L"), aiClientOption); + + string input = """ + 对于那些照顾室内植物的人来说,过度浇水是一个常见的问题。 + 为了防止这种情况,让土壤在两次浇水之间变干至关重要。 + 与其按照固定的时间表浇水,不如考虑使用水分计来准确测量土壤的湿度。 + 如果土壤保持水分,明智的做法是再推迟几天浇水。 + 如有疑问,“节约用水,保持少即是多”的方法通常更安全。 + """; + + BinaryData speech = client.GenerateSpeech(input, GeneratedSpeechVoice.Alloy); + + using FileStream stream = File.OpenWrite($"{Guid.NewGuid()}.mp3"); + speech.ToStream().CopyTo(stream); + } + + /// + /// 语音转文本 + /// + [Fact(Skip ="因本地Ollama测试环境,不支持OpenAI音频接口,忽略测试")] + //[Fact] + public void AudioToText_AudioClient_Test() + { + var aiClientOption = new OpenAIClientOptions() + { + Endpoint = new Uri("https://sg.uiuiapi.com/v1") + }; + + AudioClient client = new("whisper-1", new ApiKeyCredential("sk-4azuOUkbzNGP22pQkND8ad1vZl7ladwBQyqGKlWWZyxYgX1L"), aiClientOption); + + string audioFilePath = Path.Combine(Environment.CurrentDirectory, "Assets", "yuxia.mp3"); + + AudioTranscription transcription = client.TranscribeAudio(audioFilePath); + + _output.WriteLine($"{transcription.Text}"); + } #endregion #region 如何将响应与流式处理和推理结合使用 + [Fact(Skip ="因本地Ollama测试环境不支持,忽略测试")] + public void Responses_With_Streaming_Reasoning_ChatClient_Test() + { + + } #endregion + [Fact] #region 如何将响应与文件搜索一起使用 + public async Task Respones_With_FileSearch_Test() + { +#pragma warning disable OPENAI001 + OpenAIResponseClient client = new( + model: "gpt-4o-mini", + apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")); + + ResponseTool fileSearchTool = ResponseTool.CreateFileSearchTool(vectorStoreIds: ["sssssssss"]); + OpenAIResponse response = await client.CreateResponseAsync + ( + userInputText: "According to available files, what's the secret number?", + new ResponseCreationOptions() + { + Tools = { fileSearchTool } + } + ); + + foreach (ResponseItem outputItem in response.OutputItems) + { + if (outputItem is FileSearchCallResponseItem fileSearchCall) + { + Console.WriteLine($"[file_search] ({fileSearchCall.Status}): {fileSearchCall.Id}"); + foreach (string query in fileSearchCall.Queries) + { + Console.WriteLine($" - {query}"); + } + } + else if (outputItem is MessageResponseItem message) + { + Console.WriteLine($"[{message.Role}] {message.Content.FirstOrDefault()?.Text}"); + } + } +#pragma warning restore OPENAI001 + } #endregion #region 如何将响应与网络搜索结合使用 + [Fact] + public async Task WebSearch_ChatClient_Test() + { +#pragma warning disable OPENAI001 + OpenAIResponseClient client = _defaultOpenAIClient.GetOpenAIResponseClient(ModelSelecter.ModelWithRawmodel); + + OpenAIResponse response = await client.CreateResponseAsync + ( + userInputText: "What's a happy news headline from today?", + new ResponseCreationOptions() + { + Tools = { ResponseTool.CreateWebSearchTool() }, + } + ); + + foreach (ResponseItem item in response.OutputItems) + { + if (item is WebSearchCallResponseItem webSearchCall) + { + Console.WriteLine($"[Web search invoked]({webSearchCall.Status}) {webSearchCall.Id}"); + } + else if (item is MessageResponseItem message) + { + Console.WriteLine($"[{message.Role}] {message.Content?.FirstOrDefault()?.Text}"); + } + } + #pragma warning restore OPENAI001 + } #endregion #region 如何生成文本嵌入 @@ -131,4 +551,18 @@ public class OpenAISdkTest #region 高级方案 #endregion + + #region 私有方法 + private static string GetCurrentLocation() + { + // Call the location API here. + return "San Francisco"; + } + + private static string GetCurrentWeather(string location, string unit = "celsius") + { + // Call the weather API here. + return $"31 {unit}"; + } + #endregion } diff --git a/OllamaStudy.UseExtensionsAI/Startup.cs b/OllamaStudy.UseExtensionsAI/Startup.cs index e632b80..1d84f63 100644 --- a/OllamaStudy.UseExtensionsAI/Startup.cs +++ b/OllamaStudy.UseExtensionsAI/Startup.cs @@ -91,6 +91,19 @@ namespace OllamaStudy.UseExtensionsAI }; return new ChatClient(options.Model,new ApiKeyCredential("nokey"),openAIClientOptions); + }) + + //OpenAI 客户端是线程安全的,可安全的注册为单例 + .AddKeyedSingleton("OpenAIChatClient",(provider,obj) => + { + var options = provider.GetRequiredService>().CurrentValue; + + var openAIClientOptions = new OpenAIClientOptions() + { + Endpoint = new Uri(new Uri(options.OllamaServerUrl), "v1") + }; + + return new ChatClient(options.Model, new ApiKeyCredential("nokey"), openAIClientOptions); }); } #endregion diff --git a/OllamaStudy.UseOllamaSharp/OllamaStudy.UseOllamaSharp.csproj b/OllamaStudy.UseOllamaSharp/OllamaStudy.UseOllamaSharp.csproj index 85d77b8..0267971 100644 --- a/OllamaStudy.UseOllamaSharp/OllamaStudy.UseOllamaSharp.csproj +++ b/OllamaStudy.UseOllamaSharp/OllamaStudy.UseOllamaSharp.csproj @@ -15,7 +15,7 @@ - +