diff --git a/.env b/.env index 0da02dd..6f8a961 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -VITE_TOKEN=pat_4KAirQsqDKWsZrQx2e0CJ2Lo2t0KTh4WbzYlylGGOqeSoXvifbnZn1GbkUDtECII -VITE_BOT_ID=7470493448832172049 \ No newline at end of file +VITE_TOKEN=pat_oQFI8coeYjMX5gpokCM7hlaCpokoybY7ZSGKns3ttI7R4qwxPhBIvuPLZj7zTUh7 +VITE_BOT_ID=7477148643439968264 \ No newline at end of file diff --git a/README.md b/README.md index 1ce66d5..d8a905f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # 项目简介 - 一个基于coze-api的AI对话LLM组件,支持流式输出,支持图片、视频、音频、文件等类型消息的传输,记录历史对话 + 一个基于coze-api的AI对话LLM组件 + 👏🏻支持流式输出ai对话并展示markdown格式 + ✍🏻支持图片、视频、音频、文件等类型消息的传输 + ⭐️记录历史对话折叠框 + ✨切换黑白主题 + 🧉支持H5端适配 使用时需要自行配置bot ID与api key,调整于根目录.env文件中 - 后台官网https://api.coze.com + 后台官网:https://api.coze.com 在线预览:https://charlotte21110.github.io/byteDanceLLM/ # node版本 @@ -45,4 +50,6 @@ git pull origin develop // 拉取最新代码 git add xxx.js // 示例添加文件 pnpm commit // 提交 git push origin develop // 推送到远程 -``` \ No newline at end of file +``` +# 界面预览 +![显示界面](./src/assets/picture_white.png) \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index 314ef74..d2731a9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,12 +1,24 @@ -import { CozeAPI, COZE_COM_BASE_URL, ChatEventType, RoleType, ContentType } from '@coze/api'; +import { + CozeAPI, + COZE_COM_BASE_URL, + ChatEventType, + RoleType, + ContentType, +} from '@coze/api'; import axios from 'axios'; import { AdditionalMessage } from '../types/additionalMessage'; export interface Iquery { - query: string; + query: string; } -const token = import.meta.env?.VITE_TOKEN || window.__RUNTIME_CONFIG__?.REACT_APP_TOKEN || ''; -const botId = import.meta.env?.VITE_BOT_ID || window.__RUNTIME_CONFIG__?.REACT_APP_BOT_ID || ''; +const token = + import.meta.env?.VITE_TOKEN || + window.__RUNTIME_CONFIG__?.REACT_APP_TOKEN || + ''; +const botId = + import.meta.env?.VITE_BOT_ID || + window.__RUNTIME_CONFIG__?.REACT_APP_BOT_ID || + ''; const client = new CozeAPI({ token: token, @@ -14,36 +26,91 @@ const client = new CozeAPI({ baseURL: '/api', headers: new Headers({ 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, + Authorization: `Bearer ${token}`, }), }); export const fetchAIResponse = async ( - input: string, + input: string, additionalMessages: AdditionalMessage[], onData: (data: string) => void, messageType: string, - signal?: AbortSignal, + signal?: AbortSignal ): Promise => { - const contentTypeMap: { [key: string] : string} = { + const contentTypeMap: { [key: string]: string } = { text: 'text', image: 'object_string', - } + }; const contentType = contentTypeMap[messageType] || 'text'; + try { - const stream = await client.chat.stream({ - bot_id: botId, - auto_save_history: true, - user_id: '123', - additional_messages: [ - ...additionalMessages, - { - role: RoleType.User, - content: input, - content_type: contentType as ContentType, - }, - ], - }, { signal }); + // 如果不使用mock数据,就打开这一段代码,底下那段注释掉 + + /** 调api开始 */ + + const stream = await client.chat.stream( + { + bot_id: botId, + auto_save_history: true, + user_id: '123', + additional_messages: [ + ...additionalMessages, + { + role: RoleType.User, + content: input, + content_type: contentType as ContentType, + }, + ], + }, + { signal } + ); + + /** 调api结束 */ + + /** 读mock数据开始(还有一点问题服了) */ + + // const mockResponse = await fetch('/src/mock/response.txt'); + // const text = await mockResponse.text(); + // const events = text.split('\n').filter(line => line.trim()); + + // // 创建一个异步迭代器来模拟流式响应 + // const stream = { + // [Symbol.asyncIterator]() { + // let index = 0; + // return { + // async next(): Promise> { + // if (index >= events.length) { + // return { done: true, value: undefined }; + // } + // const event = events[index]; + // index++; + + // if (event.startsWith('event:')) { + // const eventType = event.split(':')[1].trim(); + // const dataLine = events[index]; + // index++; + + // if (dataLine && dataLine.startsWith('data:')) { + // const data = JSON.parse(dataLine.slice(5)); + // return { + // value: { + // event: eventType, + // data: data + // }, + // done: false + // }; + // } + // } + + // return this.next(); + // } + // }; + // } + + // }; + + /** 读mock数据结束 */ + const followUps: string[] = []; for await (const part of stream) { if (signal?.aborted) { break; @@ -51,9 +118,26 @@ export const fetchAIResponse = async ( if (part.event === ChatEventType.CONVERSATION_MESSAGE_DELTA) { onData(part.data.content); } + if (part.event === ChatEventType.CONVERSATION_MESSAGE_COMPLETED) { + // 拿回复结束的时候的时候紧接着的建议 + if ('type' in part.data && part.data.type === 'follow_up') { + onData( + JSON.stringify({ + type: 'suggestions', + suggestions: [...followUps, part.data.content], + }) + ); + followUps.push(part.data.content); + } + } } } catch (error: unknown) { - if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') { + if ( + error && + typeof error === 'object' && + 'name' in error && + error.name === 'AbortError' + ) { onData('\n[已停止回复]'); } else { console.error('Error fetching AI response:', error); @@ -67,19 +151,23 @@ export const uploadFile = async (file: File) => { formData.append('file', file); try { - const response = await axios.post(`${COZE_COM_BASE_URL}/v1/files/upload`, formData, { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'multipart/form-data', + const response = await axios.post( + `${COZE_COM_BASE_URL}/v1/files/upload`, + formData, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'multipart/form-data', + }, } - }); + ); if (response.data.code === 0) { return response.data.data; } else { - throw new Error(response.data.data) + throw new Error(response.data.data); } } catch (error) { console.error('上传文件发生错误:', error); throw error; } -} +}; diff --git a/src/assets/picture_white.png b/src/assets/picture_white.png new file mode 100644 index 0000000..4b8f78a Binary files /dev/null and b/src/assets/picture_white.png differ diff --git a/src/components/AIanswer/CodeBlock.tsx b/src/components/AIanswer/CodeBlock.tsx index 8d76cfe..b6958bf 100644 --- a/src/components/AIanswer/CodeBlock.tsx +++ b/src/components/AIanswer/CodeBlock.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import hljs from '../../utils/highlightConfig'; +import './index.css'; interface CodeBlockProps { language: string; @@ -21,39 +22,11 @@ const CodeBlock: React.FC = ({ language, value }) => { return (
- -
-        
+      
+        
       
); diff --git a/src/components/AIanswer/index.css b/src/components/AIanswer/index.css index f9b92c7..5befa22 100644 --- a/src/components/AIanswer/index.css +++ b/src/components/AIanswer/index.css @@ -72,3 +72,51 @@ .chat-ai-answer a { color: var(--accent-color); } + +.chat-ai-answer-suggestion-box { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 15px; + margin-left: 10px; + flex-wrap: wrap; +} + +.chat-ai-answer-suggestion-item { + width: fit-content; + display: inline-block; + padding: 8px 15px; + background-color: var(--button-bg); + color: var(--button-text); + border-radius: 15px; + cursor: pointer; + font-size: 14px; + transition: all 0.3s ease; + border: 1px solid var(--border-color); +} + +.copy-button { + position: absolute; + right: 10px; + top: 10px; + background: none; + border: none; + cursor: pointer; + color: var(--accent-color); + border-radius: 5px; + padding: 5px 10px; + background-color: var(--button-bg); +} + +.code-pre { + background-color: #1e1e1e; + padding: 1em; + border-radius: 6px; + overflow: auto; +} + +.code-pre code { + font-family: Consolas, Monaco, monospace; + background-color: black; + color: #fff; +} diff --git a/src/components/AIanswer/index.tsx b/src/components/AIanswer/index.tsx index af01a85..cb5dd5b 100644 --- a/src/components/AIanswer/index.tsx +++ b/src/components/AIanswer/index.tsx @@ -6,55 +6,108 @@ import CodeBlock from './CodeBlock'; interface AIanswerProps { content: string; duration?: number; + onSuggestClick?: (suggestion: string) => void; } interface CodeProps { inline?: boolean; className?: string; children: React.ReactNode; } - +// TODO: 有组件多次重复渲染的问题,需要优化 const AIanswer = (props: AIanswerProps) => { - const { content } = props; + const { content, onSuggestClick } = props; + const [answer, setAnswer] = React.useState(''); + const [suggestions, setSuggestions] = React.useState([]); + + React.useEffect(() => { + const suggestionMatch = content.match(/\{"type":"suggestions".*?\}/g); + if (suggestionMatch) { + try { + const lastSuggestion = suggestionMatch[suggestionMatch.length - 1]; + const parsed = JSON.parse(lastSuggestion); + setSuggestions(parsed.suggestions); + + // 提取非建议部分的文本 + const textContent = content.split(/\{"type":"suggestions".*?\}/)[0]; + if (textContent) { + setAnswer(textContent); + } + } catch (e) { + console.log('解析建议失败:', e); + } + } else { + // 如果没有建议,直接设置文本内容 + setAnswer(content); + } + }, [content]); return (
-
- { - if (inline) { +
+
+ { + if (inline) { + return ( + + {children} + + ); + } + const language = className + ? className.replace('language-', '') + : ''; return ( - - {children} - + ); - } - const language = className - ? className.replace('language-', '') - : ''; - return ( - - ); - }, - }} - > - {content} - + }, + }} + > + {answer} + +
+ +
+ {suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( +
onSuggestClick?.(suggestion)} + className="chat-ai-answer-suggestion-item" + // 用hover效果 + onMouseOver={(e) => { + e.currentTarget.style.transform = 'scale(1.05)'; + e.currentTarget.style.boxShadow = + '0 2px 8px var(--shadow-color)'; + }} + onMouseOut={(e) => { + e.currentTarget.style.transform = 'scale(1)'; + e.currentTarget.style.boxShadow = 'none'; + }} + > + {suggestion} +
+ ))} +
+ )} +
); diff --git a/src/components/ChatLLM/index.tsx b/src/components/ChatLLM/index.tsx index a0107ff..d65f579 100644 --- a/src/components/ChatLLM/index.tsx +++ b/src/components/ChatLLM/index.tsx @@ -9,6 +9,8 @@ import { CheckOutlined, CopyOutlined, BarsOutlined } from '@ant-design/icons'; import HistorySidebar from '../Sidebar'; import { RoleType, ContentType } from '@coze/api'; import ChatInput from '../ChatInput'; +import { RedoOutlined } from '@ant-design/icons'; +import { Tooltip } from 'antd'; const { Sider, Content } = Layout; @@ -178,8 +180,10 @@ const ChatLLM = () => { } }; const handleCopy = (index: number, content: string) => { + // 过滤掉建议部分,只复制实际内容,抽象的做法(扶额) + const textContent = content.split(/\{"type":"suggestions".*?\}/)[0]; navigator.clipboard - .writeText(content) + .writeText(textContent) .then(() => { setCombinedContents((prevContents) => { const newContents = [...prevContents]; @@ -199,6 +203,81 @@ const ChatLLM = () => { }); }; + const onRetry = async (combinedIndex: number) => { + const conversationIndex = Math.floor(combinedIndex / 2); + if (!guestContents[conversationIndex]) { + console.error('找不到对应的用户消息'); + return; + } + const contentToRetry = guestContents[conversationIndex].content; + let aiContent = ''; + + setAIContents((prevContents) => { + const newContents = [...prevContents]; + if (newContents[conversationIndex]) { + newContents[conversationIndex] = { + ...newContents[conversationIndex], + content: '', + }; + } + return newContents; + }); + + const controller = new AbortController(); + setAbortController(controller); + setIsResponding(true); + + // 重新记录回答开始时间 + const startTime = Date.now(); + + const additionalMessages = combinedContents.map((content) => ({ + role: content.type === 'guest' ? RoleType.User : RoleType.Assistant, + content: content.content, + content_type: content.fileId + ? ('object_string' as ContentType) + : ('text' as ContentType), + })); + + try { + await fetchAIResponse( + contentToRetry, + additionalMessages, + (data: string) => { + aiContent += data; + setAIContents((prevContents) => { + const newContents = [...prevContents]; + if (newContents[conversationIndex]) { + newContents[conversationIndex] = { + ...newContents[conversationIndex], + content: aiContent, + }; + } + return newContents; + }); + }, + 'text', + controller.signal + ); + } finally { + setIsResponding(false); + setAbortController(null); + + // 计算回答时间并更新 duration + const answerDuration = Date.now() - startTime; + setAIContents((prevContents) => { + const newContents = [...prevContents]; + if (newContents.length > conversationIndex) { + newContents[conversationIndex] = { + ...newContents[conversationIndex], + duration: answerDuration, + isCopied: false, + }; + } + return newContents; + }); + } + }; + const clearImage = () => { setPreviewImage(null); const input = document.getElementById('file-input') as HTMLInputElement; @@ -434,6 +513,7 @@ const ChatLLM = () => { onSearch(suggestion)} /> {content.duration !== undefined && (
{ marginRight: '15px', }} > + + onRetry(index)} + style={{ + fontSize: '16px', + cursor: 'pointer', + marginRight: '8px', + }} + /> +