随着ChatGPT的火热,带动了大模型的军备竞赛。目前国内有很多厂商做的大模型已经有了不错的效果,目前我使用过的智普AI和QWen都不错。那么小公司可能没有能力去训练大模型,但是打造大模型应用的生态已经非常火热。
大模型有开源和商业可用,商业的价格也越来越低,效果也越来越好,开源方案可以使用Ollama搭配QWen2。开发框架方面,Python有LangChain,Java有Spring AI,甚至不会后端开发的也有可视化编排的SaaS可用,可以提供大模型服务,比如dify。
这里介绍一种方案用来构建一个类似ChatGPT的聊天助手功能。
技术选型

SSE
SSE(Server-Sent Events,服务器发送事件)是一种从服务器到客户端的单向通信方式,主要用于实现服务器向浏览器持续推送更新数据的场景。它基于HTTP协议,并且使用文本流(text/event-stream)作为数据格式。
这种通信方式只支持文本,不支持二进制,相对于websocket来说,它是单向通信。
在这里我们使用SSE,主要是将后端调用大模型返回的流式数据持续的推送到前端进行展示,呈现出类似ChatGPT的打字的效果。
大模型API
这里使用的国内公司智普AI的大模型API。它有免费的大模型可以使用。
我们只需要去它的官网进行注册,拿到所需的appkey,在配置调用大模型时,选择免费的模型即可。
这是官网

Spring AI
Spring AI是Spring 官方为了应对Java在大模型开发方面的欠缺,打造的一个大模型应用开发组件,用于集成各类大模型的能力。目前已经发布1.0的版本。最低支持Java17版本。
另一款知名的大模型开发框架LangChain主要是用于Python和JS。
Spring AI的集成通过官网的例子还是很容易实现的。
这是集成智普AI的例子
实现细节
后端
- 按照官网的要求,引入依赖项。
- 配置Application.yml
spring:
ai:
zhipuai:
api-key: 你在智普官网注册后申请的apikey
chat:
options:
model: GLM-4-Flash
- 接口实现
@RestController
@RequestMapping("/api/v1/ai")
@Slf4j
public class ChatController {
private final ZhiPuAiChatModel chatModel;
private final ChatClient chatClient;
public ChatController(ZhiPuAiChatModel chatModel) {
this.chatModel = chatModel;
this.chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(new InMemoryChatMemory()))
.build();
;
}
@GetMapping("/generate")
public Map generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
return Map.of("generation", chatModel.call(message));
}
@CrossOrigin(origins = "http://localhost:5173/")
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestBody String prompt) {
return chatClient.prompt()
.advisors(advisor -> advisor.param("chat_memory_conversation_id", "前端传入的随机字符串") // 作为对话记忆的id,区分不同用户的对话记录。
.param("chat_memory_response_size", 100))
.user(prompt)
.stream()
.content()
.filter(Objects::nonNull)
.map(content -> "'" + content);
}
}
@CrossOrigin: 允许跨域访问。
produces =
MediaType.TEXT_EVENT_STREAM_VALUE: 开启SSE。
- 安全
如果系统使用了像Spring Security 的安全框架,需要允许SSE请求的路径通过认证,也就是不对SSE请求进行认证。虽然没有进行安全认证,跨域的限制也一定程度保证了接口的安全。
前端
前端主要通过引入@
microsoft/fetch-event-source来实现SSE的功能。
import {fetchEventSource} from '@microsoft/fetch-event-source';
const fetchFromOpenAI = async (userInput, suggestionType, onMessage) => {
const responseText = [];
const url = new URL('http://localhost:8080/api/v1/ai/generateStream');
await fetchEventSource(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token') // 携带认证token
},
body: JSON.stringify({
"message": userInput,
}),
onopen(response) {
// 请求成功打开后触发
console.log('Connection opened');
},
onmessage(msg) {
onMessage(msg);
},
});
return responseText.join('');
};
onMessage方法用于处理接收到的后端文本数据。
这里是一个详细的前端代码供参考:
import {fetchEventSource} from '@microsoft/fetch-event-source';
import { Helmet } from 'react-helmet-async';
import ReactMarkdown from 'react-markdown';
import {CopyToClipboard} from 'react-copy-to-clipboard';
import remarkGfm from 'remark-gfm'; // 引入remark-gfm插件
import {Light as SyntaxHighlighter} from 'react-syntax-highlighter';
import {atomOneDark} from 'react-syntax-highlighter/dist/esm/styles/hljs';
import React, {useEffect, useRef, useState} from "react";
import { agentImg } from '@/utils'
import {FaCheck, FaCopy} from "react-icons/fa";
import './chat.css'
import {CornerDownLeft, Mic, Paperclip} from "lucide-react";
import {Button} from "@/components/ui/button";
import {TooltipContent} from "@/components/ui/tooltip";
import {Tooltip, TooltipProvider, TooltipTrigger} from "@radix-ui/react-tooltip";
import {Textarea} from "@/components/ui/textarea";
import {Label} from "@/components/ui/label";
import {ScrollArea} from "@/components/ui/scroll-area";
import {useAccountStore} from "@/store/accountStore";
import {BiCircle} from "react-icons/bi";
export function AiChat() {
//从后台获取的账户信息
const {account} = useAccountStore();
const [input, setInput] = useState('');
const [copied, setCopied] = useState(false); // 跟踪是否已拷贝
const [messages, setMessages] = useState([{
text: '你好,请问有什么可以帮您的吗?',
sender: 'bot',
avatar: <img src={agentImg} alt="ChatGPT" className="w-6 h-6 rounded-full mt-1 mr-2"/>
}]);
const textareaRef = useRef(null);
const messagesEndRef = useRef(null);
const [botState, setBotState] = useState('idle'); // 状态:idle, typing, thinking, done
const handleSend = async (question?, suggestionType?) => {
question = question || input.trim()
if (question) {
const newMessages = [
...messages,
{
text: question,
sender: 'user',
avatar: <img src={'data:image/png;base64,' + account.avatar} alt="avatar" className="w-6 h-6 rounded-full ml-2"/>
}
];
setMessages(newMessages);
setInput('');
setBotState('thinking'); // 机器人进入思考状态
const botMessage = {
text: '',
sender: 'bot',
avatar: <img src={agentImg} alt="ChatGPT" className="w-6 h-6 rounded-full mt-1 mr-2"/>,
};
setMessages([...newMessages, botMessage]);
await fetchFromOpenAI(question, suggestionType, [], (msg) => {
setBotState('typing'); // 机器人进入输入状态
botMessage.text += msg.data.slice(1);
setMessages([...newMessages, {...botMessage}]);
});
setBotState('done'); // 机器人完成输出状态
}
};
const fetchFromOpenAI = async (userInput, suggestionType, files, onMessage) => {
const responseText = [];
//请求后端的chat接口地址
const url = new URL('http://localhost:8080/api/v1/ai/generateStream');
await fetchEventSource(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
"message": userInput,
}),
onopen(response) {
// 请求成功打开后触发
console.log('Connection opened');
},
onmessage(msg) {
onMessage(msg);
},
});
return responseText.join('');
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
// 这里可以添加提交逻辑
handleSend()
} else if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
setInput((prevText) => prevText + '\n');
}
};
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight + 8}px`;
if (textarea.scrollHeight > textarea.clientHeight) {
textarea.style.overflowY = 'auto';
} else {
textarea.style.overflowY = 'hidden';
}
}
}, [input]);
useEffect(() => {
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({behavior: 'smooth'});
};
scrollToBottom();
}, [messages]);
const renderers = {
ul: ({children}) => <ul className="list-disc list-inside">{children}</ul>,
ol: ({children}) => <ol className="list-decimal list-inside">{children}</ol>,
li: ({children}) => <li className="my-2">{children}</li>,
code: ({node, inline, className, children, ...props}) => {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<div className="relative rounded-md">
<SyntaxHighlighter
style={atomOneDark}
language={match[1]}
PreTag="div"
children={String(children).replace(/\n$/, '')}
{...props}
/>
<CopyToClipboard
text={String(children).replace(/\n$/, '')}
onCopy={() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000); // 2秒后清除 "已拷贝" 状态
}}
>
<button
className="absolute top-0 right-0 m-2 p-1 bg-gray-700 rounded-md text-white hover:bg-gray-600">
{copied ? <FaCheck/> : <FaCopy/>}
</button>
</CopyToClipboard>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
};
return (
<div className="flex">
<Helmet>
<title>智能助手</title>
</Helmet>
<div className="mx-auto w-full max-w-4xl flex flex-col h-[calc(100vh_-_theme(spacing.24))]">
<ScrollArea className={'flex-1'}>
<div className="flex w-full flex-col rounded-xl p-2">
{messages.map((msg, index) => (
<div
key={index}
className={`max-w-3xl flex items-start select-text ${
msg.sender === 'user'
? 'my-2 px-8 py-4 rounded-3xl self-end'
: 'my-2 p-2 self-start'
}`}
>
{msg.sender === 'bot' && msg.avatar}
{msg.sender === 'bot' && messages.length == index + 1 && botState == 'thinking' &&
<div className={'mt-3'}>
<span className="relative flex h-4 w-4">
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-lime-200 opacity-75"></span>
<span className="relative inline-flex rounded-full h-4 w-4 bg-lime-400"></span>
</span>
</div>
}
<div>
{msg.sender === 'user' ? (
<div className={'flex items-center justify-center'}>
<div className='whitespace-pre-wrap'>{msg.text}</div>
{msg.avatar}
</div>) :
<div>
<ReactMarkdown
className="markdown-body"
remarkPlugins={[remarkGfm]} // 使用remark-gfm插件
components={renderers}
>
{msg.text}
</ReactMarkdown>
</div>
}
</div>
</div>
))}
<div ref={messagesEndRef}/>
</div>
</ScrollArea>
<div
className="mb-1 relative overflow-hidden rounded-lg border bg-background focus-within:ring-1 focus-within:ring-ring" x-chunk="dashboard-03-chunk-1"
>
<Label htmlFor="message" className="sr-only">
Message
</Label>
<Textarea
id="message"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="在这里输入你的问题跟智能助手对话..."
className="min-h-12 resize-none border-0 p-3 shadow-none focus-visible:ring-0"
/>
<div className="flex items-center p-3 pt-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Paperclip className="size-4" />
<span className="sr-only">Attach file</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">Attach File</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Mic className="size-4" />
<span className="sr-only">Use Microphone</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">Use Microphone</TooltipContent>
</Tooltip>
</TooltipProvider>
{
botState == 'typing' ?
<BiCircle size={28} className='ml-auto gap-1.5'/> :
<Button type="submit" size="sm" className="ml-auto gap-1.5"
onClick={() => handleSend()}>
发送信息
<CornerDownLeft className="size-3.5"/>
</Button>
}
</div>
</div>
</div>
</div>
)
}
页面使用的Shadcn/ui提供的现成的block,这个UI框架提供了一个大模型聊天对话界面。

因为大模型返回的数据一般是md格式的数据,所以引入了markdown组件,用于显示md格式数据,包括代码。
最后2个useEffect钩子函数用于保持输出时,一直显示最底部的文字。
最后
输入框还有上传和语音按钮,一个用于上传文本,作为提问的上下文,一个用于支持语音输入,这些都是可以实现的,感兴趣的评论区留言,考虑出一篇来介绍。Enjoy!
11万+

被折叠的 条评论
为什么被折叠?



