最近 chatgpt 很火,由於網頁版本限制了 ip,還得必須開代理,用起來比較麻煩,所以我嘗試用 maui 開發一個聊天小應用,結合 chatgpt 的開放 api 來實現(很多客戶端使用網頁版本介面用 cookie 的方式,有很多限制(如下圖)總歸不是很正規)。
效果如下
mac 端由於需要升級 macos13 才能開發除錯,這部分我還沒有完成,不過 maui 的控制元件是跨平臺的,放在後續我升級系統再說。
開發實戰
我是設想開發一個類似 jetbrains 的 ToolBox 應用一樣,啟動程式在桌面右下角出現托盤圖示,點選圖示彈出應用(風格在 windows mac 平臺保持一致)
需要實現的功能一覽
- 托盤圖示(右鍵點選有 menu)
- webview(js 和 csharp 互相呼叫)
- 聊天 SPA 頁面(react 開發,build 後讓 webview 展示)
新建一個 maui 工程(vs2022)
坑一:預設編譯出來的 exe 是直接雙擊打不開的
工程檔案加上這個配置
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained Condition="'$(IsUnpackaged)' == 'true'">true</WindowsAppSDKSelfContained>
<SelfContained Condition="'$(IsUnpackaged)' == 'true'">true</SelfContained>
以上修改後,編譯出來的 exe 雙擊就可以開啟了
托盤圖示(右鍵點選有 menu)
啟動時設定視窗不能改變大小,隱藏 titlebar, 讓 Webview 控制元件佔滿整個視窗
這裡要根據平臺不同實現不同了,windows 平臺採用 winAPI 呼叫,具體看工程程式碼吧!
WebView
在 MainPage.xaml 新增控制元件
對應的靜態 html 等檔案放在工程的 Resource\Raw 資料夾下 (整個資料夾裡面預設是作為內嵌資源打包的,工程檔案裡面的如下配置起的作用)
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
【重點】js 和 csharp 互相呼叫
這部分我找了很多資料,最終參考了這個 demo,然後改進了下。
主要原理是:
- js 呼叫 csharp 方法前先把資料儲存在 localstorage 裡
- 然後 windows.location 切換特定的 url 發起呼叫,返回一個 promise,等待 csharp 的事件
- csharp 端監聽 webview 的 Navigating 事件,非同步進行下面處理
- 根據 url 解析出來 localstorage 的 key
- 然後 csharp 端呼叫 excutescript 根據 key 拿到 localstorage 的 value
- 進行邏輯處理後返回透過事件分發到 js 端
js 的呼叫封裝如下:
// 呼叫csharp的方法封裝
export default class CsharpMethod {
constructor(command, data) {
this.RequestPrefix = "request_csharp_";
this.ResponsePrefix = "response_csharp_";
// 唯一
this.dataId = this.RequestPrefix + new Date().getTime();
// 呼叫csharp的命令
this.command = command;
// 引數
this.data = { command: command, data: !data ? '' : JSON.stringify(data), key: this.dataId }
}
// 呼叫csharp 返回promise
call() {
// 把data儲存到localstorage中 目的是讓csharp端獲取引數
localStorage.setItem(this.dataId, this.utf8_to_b64(JSON.stringify(this.data)));
let eventKey = this.dataId.replace(this.RequestPrefix, this.ResponsePrefix);
let that = this;
const promise = new Promise(function (resolve, reject) {
const eventHandler = function (e) {
window.removeEventListener(eventKey, eventHandler);
let resp = e.newValue;
if (resp) {
// 從base64轉換
let realData = that.b64_to_utf8(resp);
if (realData.startsWith('err:')) {
reject(realData.substr(4));
} else {
resolve(realData);
}
} else {
reject("unknown error :" + eventKey);
}
};
// 註冊監聽回撥(csharp端處理完發起的)
window.addEventListener(eventKey, eventHandler);
});
// 改變location 傳送給csharp端
window.location = "/api/" + this.dataId;
return promise;
}
// 轉成base64 解決中文亂碼
utf8_to_b64(str) {
return window.btoa(unescape(encodeURIComponent(str)));
}
// 從base64轉過來 解決中文亂碼
b64_to_utf8(str) {
return decodeURIComponent(escape(window.atob(str)));
}
}
前端的使用方式
import CsharpMethod from '../../services/api'
// 發起呼叫csharp的chat事件函式
const method = new CsharpMethod("chat", {msg: message});
method.call() // call返回promise
.then(data =>{
// 拿到csharp端的返回後展示
onMessageHandler({
message: data,
username: 'Robot',
type: 'chat_message'
});
}).catch(err => {
alert(err);
});
csharp 端的處理:
這麼封裝後,js 和 csharp 的互相呼叫就很方便了。
chatgpt 的開放 api 呼叫
註冊好 chatgpt 後可以申請一個 APIKEY。
API 封裝:
public static async Task<CompletionsResponse> GetResponseDataAsync(string prompt)
{
// Set up the API URL and API key
string apiUrl = "https://api.openai.com/v1/completions";
// Get the request body JSON
decimal temperature = decimal.Parse(Setting.Temperature, CultureInfo.InvariantCulture);
int maxTokens = int.Parse(Setting.MaxTokens, CultureInfo.InvariantCulture);
string requestBodyJson = GetRequestBodyJson(prompt, temperature, maxTokens);
// Send the API request and get the response data
return await SendApiRequestAsync(apiUrl, Setting.ApiKey, requestBodyJson);
}
private static string GetRequestBodyJson(string prompt, decimal temperature, int maxTokens)
{
// Set up the request body
var requestBody = new CompletionsRequestBody
{
Model = "text-davinci-003",
Prompt = prompt,
Temperature = temperature,
MaxTokens = maxTokens,
TopP = 1.0m,
FrequencyPenalty = 0.0m,
PresencePenalty = 0.0m,
N = 1,
Stop = "[END]",
};
// Create a new JsonSerializerOptions object with the IgnoreNullValues and IgnoreReadOnlyProperties properties set to true
var serializerOptions = new JsonSerializerOptions
{
IgnoreNullValues = true,
IgnoreReadOnlyProperties = true,
};
// Serialize the request body to JSON using the JsonSerializer.Serialize method overload that takes a JsonSerializerOptions parameter
return JsonSerializer.Serialize(requestBody, serializerOptions);
}
private static async Task<CompletionsResponse> SendApiRequestAsync(string apiUrl, string apiKey, string requestBodyJson)
{
// Create a new HttpClient for making the API request
using HttpClient client = new HttpClient();
// Set the API key in the request headers
client.DefaultRequestHeaders.Add("Authorization", "Bearer " + apiKey);
// Create a new StringContent object with the JSON payload and the correct content type
StringContent content = new StringContent(requestBodyJson, Encoding.UTF8, "application/json");
// Send the API request and get the response
HttpResponseMessage response = await client.PostAsync(apiUrl, content);
// Deserialize the response
var responseBody = await response.Content.ReadAsStringAsync();
// Return the response data
return JsonSerializer.Deserialize<CompletionsResponse>(responseBody);
}
呼叫方式
var reply = await ChatService.GetResponseDataAsync('xxxxxxxxxx');
在學習 maui 的過程中,遇到問題我在 Microsoft Learn 提問,回答的效率很快,推薦大家試試看!