雖然主題是 Spring AI, 不過沒前端總覺得少了什麼,所以特別整理一篇前端開發類似 ChatGPT 網頁需注意的重點,先來看看成果吧
這次後端的部分使用 Day4 的程式碼,使用 Spring AI 做流式輸出有個特別的地方 Flux<String> chatModel.stream(prompt)
prompt 若使用 String 則回傳得資料也會是 String 的片段,不過也因此在處理上有些莫名其妙的問題,若想使用 Event 的方式處理訊息最好使用 Prompt 物件來傳送內容,這樣回傳資料就會是 Flux<ChatResponse>
,前端處理時使用 Json 比較不用處理特殊符號
LLM 輸出的內容基本都是 Markdown 格式,以使用 React 為例,輸出對話使用 MUI 的 List元件,若要能呈現 Markdown 效果還是需要另外安裝 Markdown 元件,另外要支援 code 語法還需要安裝插件
import ReactMarkdown, { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkEmoji from 'remark-emoji';
import remarkToc from 'remark-toc';
import remarkMath from 'remark-math';
import remarkSlug from 'remark-slug';
import rehypeKatex from 'rehype-katex';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
const renderers: Components = {
code( {node, inline, className, children, ...props}: any ) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={ atomDark as any}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
};
//下方為 react 元件使用 ReactMarkdown 的方式
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkEmoji, remarkToc, remarkMath, remarkSlug as any]}
rehypePlugins={[rehypeKatex, rehypeAutolinkHeadings]}
components={renderers}>
{message.text}
</ReactMarkdown>
流式輸出雖然使用 SSE 技術,不過標準 SSE 只支援 Get 方法,而 Get 方法瀏覽器又不支援傳送 Json,原本想裝微軟的 fetchEventSource,可支援 Post 也能處理 SSE,可是不知為什麼回傳的內容空白都會消失,最後還是用 fetch 加上 useState 來處理打字機效果
使用 fetch 有以下幾個步驟
a. 因為 Stream 會不斷推送資料,Reader 讀取後結果會包含 done : boolean 以及 value : Uint8Array,當 done 是 false 時表示資料還沒傳送完,可以寫一個無窮迴圈,當 done 為 true 才跳出迴圈
b. value 型態是 Uint8Array,須透過 decoder 轉回 utf8
c. 因為不是使用 Event 方式接收資料,所以可能好幾個片段一起傳送過來,這時可用下面方式取得正確內容
const jsonChunks = chunk.split('data:').filter(Boolean);
for (let jsonChunk of jsonChunks){
newTemp += jsonChunk.replace(/\n\n/g, '');
}
SSE 傳送的資料前都會有 data:
所以需要先用這個字串拆成小片段,另外內容可能包含一些空字串,所以後面可再使用 .filter(Boolean)
過濾不要的資料
最後因為每個 data 最後都是 \n\n
結尾,我們可使用 .replace(/\n\n/g, '')
去除
List 最下面可以放置一個 ListItem,搭配useState處理最新回覆的打字機效果,其他舊訊息使用陣列存放,最新回覆處理完後也要加入訊息陣列,陣列的內容需要額外加上是用戶傳送還是 LLM 回傳,透過css就能做到對話的泡泡框
setMessages([...messages, {text:newMessage, sender: 'bot'}]);
需要中斷回覆要使用 AbortController 元件,並加入傳送的封包中,只需要做一個暫停按鈕觸發 Abort 就能停止傳送資料
const controller = new AbortController();
const promptBody:PromptBody = { 'prompt': prompt };
const response = await fetch('http://localhost:8080/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(promptBody),
signal: controller.signal,
});
//後面需要暫停時呼叫以下指令
controller.abort();
另外最近 OpenAI 釋出了 realtime 模型,這裡又使用另一個後端主動推送的技術 Websocket,websocket 與一般 http 傳輸最大的差別就是建立連線後雙向可全雙工傳輸資料,而 OpenAI 提供的架構中,用戶並非直接對到 API,而是需要一個中間層作為雙方的橋樑
Spring 不管在 Client 或是 Server 都有套件可以處理 Websocket 內容,有機會凱文大叔再來教大家寫中間轉發 Websocket 的部分