項目小結:使用 Blazor 和 gRPC 開發大模型客戶端
1 前言
先介紹下這個項目。
最近我一直在探索大語言模型,根據不同場景訓練了好幾個模型,爲了讓用戶測試使用,需要開發前端。
這時候,用 Gradio 搭建的前端是不太夠的,雖說 GitHub 上也有一堆開源的 ChatGPT 前端,但我看了一圈,並沒有找到便於二次開發定製的,再一想,這麼簡單的功能,自己做就好啦,何必去 GitHub copy 呢?
那麼就直接開始吧~
一開始我打算用 React 來做前端,然後使用 websocket 或者 eventsource 來實現聊天的打字機效果,但轉念一想,不是有 Blazor Server 嗎,這東西本來就和服務器建立了長鏈接,根本不需要折騰,於是決定試試。
2 先看效果
PC 端
移動端
3 項目設計
項目架構圖
後端使用 gRPC 與各個模型的服務連接,然後使用 Blazor 來實現聊天功能。
關於 gRPC 的使用,在之前寫的這篇博客: Asp-Net-Core 學習筆記:gRPC 快速入門
4 關於 Blazor
其實幾年前我就有輕度使用了一下 Blazor 這個技術,詳見文章: Asp-Net-Core 學習筆記:4.Blazor-WebAssembly 入門
一開始使用 Blazor ,我是有點嫌棄的,我還是比較傾向於傳統的前後端分離,AspNetCore 用來做後端,用 React 做前端,生態很豐富,要做啥組件都容易。
Blazor 有幾個痛點:
-
每次對界面的一點小修改都需要重新編譯才能看到效果
-
生態比較貧瘠,好用的組件庫還比較少
-
無論 WebAssembly 還是 server rendered 模式,都無法直接操縱 DOM,需要通過 JSRuntime
所以這個項目一開始,我好幾次產生了放棄 blazor ,重新用 React 實現的想法,不過隨着熟練度提高,反而漸漸覺得,Blazor 好像也不錯,開發小應用的效率挺高的。
這幾個痛點雖然影響體驗,但也不是不能忍受
-
每次都要編譯,是慢了點,那就改完界面休息一下😃 需要反覆修改的樣式,直接在瀏覽器的開發者工具裏面調整,調到滿意再寫到代碼裏
-
生態貧瘠問題也不大,Blazor 本質上還是前端,雖然 Blazor 原生組件少,但我可以自己寫啊,前端生態無所不有,大多數直接拿來改改就可以用了
-
無法操作 DOM 是比較麻煩,只能多寫點 JavaScript 來調用,Blazor 也並不是替代 JS,現在不會 JS 還是沒辦法做前端的
所以這個項目就這麼磕磕絆絆的,邊學 Blazor 邊做,搞定了,效果竟然還可以😂
事實證明,Blazor (Server) 用在小項目上還是可以的,Server 模式的長鏈接可以做太多事了,數據交互這一塊可以節省很多精力。
5 前端依賴
使用內置模板創建 Blazor 的項目,靜態文件直接是附帶在 wwwroot/lib
目錄下
這個還是不趁手,我習慣用 npm 管理靜態資源,第三方的前端依賴不添加到版本管理中
可以參考之前的博客: Asp-Net-Core 開發筆記:使用 NPM 和 gulp 管理前端靜態文件
本項目中,我使用了這些依賴
"dependencies": {
"@fortawesome/fontawesome-free": "^6.0.0",
"admin-lte": "^3.2.0",
"bootstrap": "^4.6.2",
"open-iconic": "^1.1.1"
}
然後發現 open-iconic 其實不怎麼好看,還是 FontAwesome 好一點。
6Blazor 實現打字機效果
聊天界面需要實現類似 ChatGPT 的打字機效果
這就得用到流式輸出,看了下 ChatGPT 的實現是 EventSource,不過我們用 Blazor Server 就不用考慮這些麻煩的數據交互問題了。
書接上回的 gRPC 調用,定義爲服務端流式輸出,然後我又在 Blazor 項目裏封裝了一個 ChatService
用生成器的方式,返回一個 IAsyncEnumerable
對象。(詳見第三個參考資料)
public async IAsyncEnumerable<string> StreamingChat(string prompt) {
using var call = ClientRoute(prompt).StreamingChat(GetRequest(prompt));
await foreach (var resp in call.ResponseStream.ReadAllAsync()) {
yield return RenderText(resp.Response);
}
}
在 Blazor 組件裏調用的時候,只需要用 await foreach
搭配 StateHasChanged()
,就可以實現打字機效果了
await foreach (var resp in ChatService.StreamingChat(input.Content)) {
output.Content = resp;
StateHasChanged();
}
PS:我們 C# 實在是太好用啦~
7DOM 操作 (JS 交互)
Blazor 無論是 server 模式,還是 WebAssembly 模式,都不能直接操作 DOM。
所以直接藉助 JS,Blazor 提供了不錯的 JS 互操作能力
我這裏用到了,聊天界面自動滾動到頁面底部的操作
首先寫一個 js 函數
function scrollToEnd(elem) {
elem.scrollTop = elem.scrollHeight
}
一開始我看官網文檔,JS 文件是可以和組件放在一起的
比如我的聊天組件,放在項目中的 Pages/Chat.razor
文件
然後 css 文件,是在 Pages/Chat.razor.css
它會自動把這倆關聯起來
Pages/_Layout.cshtml
有一個 css 引用
<link href="AIHub.Blazor.styles.css" rel="stylesheet"/>
在 build 的時候,編譯器會把所有的 css 都放在這個文件裏面
但對於 js 並不是這樣
在 Debug 模式運行,並不會自動複製 JS 文件
只有 Release 模式 publish 的時候,纔會把 wwwroot 複製到發佈目錄裏
然後還需要在 Blazor 組件裏面手動引入 這個 JS Module
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./Pages/Chat.razor.js");
之後通過這個 module 來執行 JS 調用。
但這樣調試的時候是不會複製的,拿不到 JS 很不方便。
所以我最終沒有采用這種方式,而是直接把 JS 放到 wwwroot/js
目錄下面
然後在 Pages/_Layout.cshtml
裏面引用
<script src="js/common.js"></script>
最後在 Blazor 組件裏面使用就很簡單了
8 聊天組件自動滾動到底部
前面說了 JS 互操作
現在可以在 Blazor 組件裏面調用 JS 的方法來滾動到頁面底部了
先定義個 ref (感覺和 vue 有點像)
private ElementReference _chatMessagesRef;
然後在元素上綁定
<div class="chat-messages" @ref="_chatMessagesRef"></div>
最後調用 JS 方法,把這個 ref 作爲參數傳入
await foreach (var resp in ChatService.StreamingChat(input.Content)) {
output.Content = resp;
StateHasChanged();
await Js.InvokeVoidAsync("scrollToEnd", _chatMessagesRef);
}
搞定。
PS:和 Vue 真的太像了。
9 頁面自適應
聊天界面需要同時適配電腦版和手機版
參考 Bootstrap 的自適應設計
電腦版有側邊欄,高度可以喫滿,但寬度得減去左側欄的寬度
.chat-messages {
--large-width: calc(100vw - 250px - 70px);
max-width: var(--large-width);
min-width: var(--large-width);
}
手機版沒有側欄,變成了頂欄,寬度喫滿,高度減去頂欄高度
@media screen and (max-width: 992px) {
:root {
--mobile-width: calc(100vw - 0);
--mobile-height: calc(100vh - 1.2rem - 50px);
}
.chat-wrapper {
height: var(--mobile-height);
}
.chat-messages {
max-width: var(--mobile-width);
min-width: var(--mobile-width);
}
.chat-controls {
--mobile-width: calc(100vw - 0);
max-width: var(--mobile-width);
min-width: var(--mobile-width);
}
}
Breakpoint
我只用了一個 992px
,相當於 Bootstrap 的 lg
這裏是 Bootstrap 的寬度定義,可以參考一下。
PS:最後覺得,移動端還是單獨做一個好了,自適應實在是彆扭。我突然想到之前用 Flutter 做的 App,打包成 HTML 版本,竟然體驗也還不錯。
10Blazor 組件封裝
初步用起來很簡單
比如首頁的九宮格按鈕,我就封裝了一個組件
<div class="col-xl-2 col-lg-3 col-md-4 col-6 mt-2 mb-2">
<a class="btn btn-outline-dark btn-block" href="@Href" target="@Target" style="border-color: rgba(52,58,64,.8)">
<div class="mt-2" style="">
<i class="@IconClass" style="font-size: 6em;color: #bdc6d0;"></i>
</div>
<div class="mt-2">@Title</div>
</a>
</div>
@code {
[Parameter]
public string? IconClass { get; set; }
[Parameter]
public string? Href { get; set; }
[Parameter]
public string? Target { get; set; }
[Parameter]
public string? Title { get; set; }
}
使用很簡單
<DialButton IconClass="oi oi-chat" Title="大語言模型" Href="chat"/>
搞定~
11 小結
這次只是個小 Demo 項目,試用了一下 Blazor ,從一開始的非常彆扭,到越來越順手
感覺 Blazor Server 寫小項目還是挺好用的,後面繼續完善項目,持續發掘 Blazor 的能力,到時再繼續更新博客~
12 參考資料
-
https://learn.microsoft.com/zh-cn/aspnet/core/grpc/
-
響應式設計:根據不同設備引不同 css 樣式 - https://www.cnblogs.com/supe/p/6692379.html
-
https://learn.microsoft.com/en-us/archive/msdn-magazine/2019/november/csharp-iterating-with-async-enumerables-in-csharp-8#a-tour-through-async-enumerables
-
https://learn.microsoft.com/zh-cn/aspnet/core/blazor/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/7OG63hzmp8D1rL4nWGrx6w