VUE3+TS 调用DeepSeek-R1 进行对话,流式输出,测试demo,全程无废话,直接上干货!!!
VUE3+TS 调用DeepSeek-R1 进行对话,流式输出,测试demo,全程无废话,直接上干货!!
·
背景基于Vue3+ts+vite 框架下 ,对话模式,支持流式输出
- 由于deepseek 调用接口需要支付token费用,阿里云有免费的token可使用
所以需要先注册一个阿里云领取免费的
第一步注册 阿里云-体验deepseek
第二步:申请key
第三步安装依赖:npm install markdown-it highlight.js lodash
第四步如果不嫌弃麻烦 自己画页面,嫌麻烦 copy 如下代码,直接运行
<template>
<div class="advanced-chat-container">
<!-- 带动画效果的对话区域 -->
<TransitionGroup
name="message-fade"
tag="div"
class="messages-wrapper"
@scroll.passive="handleScroll"
>
<div
v-for="(msg, index) in messages"
:key="msg.id"
:class="['message-item', msg.role]"
>
<!-- 用户消息 -->
<div v-if="msg.role === 'user'" class="message-bubble user">
{{ msg.content }}
<span class="timestamp">{{ formatTime(msg.createdAt) }}</span>
</div>
<!-- AI消息 -->
<div v-else class="message-ai">
<!-- <img :src="logo" class="sidebar-logo" /> -->
<div class="message-bubble ai">
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
<div v-if="msg.status === 'streaming'" class="streaming-cursor"></div>
<div class="status-footer">
<span class="timestamp">{{ formatTime(msg.updatedAt) }}</span>
<span v-if="msg.status === 'error'" class="retry-btn" @click="retryMessage(msg)">
⟳ 重试
</span>
</div>
</div>
</div>
</div>
<!-- 加载指示器 -->
<div v-if="loading" class="loading-indicator">
<div class="spinner"></div>
<span>菲草正在生成回答...</span>
</div>
</TransitionGroup>
<!-- 增强输入区域 -->
<div class="enhanced-input-area">
<div class="input-wrapper">
<textarea
ref="inputEl"
v-model="inputText"
:disabled="loading"
@keydown.enter.exact.prevent="sendMessage"
@keydown.shift.enter.exact="handleShiftEnter"
placeholder="输入消息(Enter发送,Shift+Enter换行)"
rows="1"
autocomplete="off"
spellcheck="false"
></textarea>
<button
class="send-btn"
:class="{ loading }"
@click="sendMessage"
:disabled="!canSend || loading"
>
<span v-if="!loading">发送</span>
<div v-else class="sending-spinner">
<span @click.stop="abortRequest" :disabled="!isStreaming">停止</span>
</div>
</button>
</div>
<div class="input-footer">
<!-- <span class="hint-text">支持Markdown语法</span> -->
<span class="char-count">{{ inputText.length }}/1000</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">import { ref, reactive, computed, nextTick, onMounted } from 'vue';
import axios from 'axios';
import OpenAI from "openai";
import MarkdownIt from 'markdown-it';
import logo from '@/assets/logo/logo.png';
import hljs from 'highlight.js';
import 'highlight.js/styles/atom-one-dark.css';
import 'highlight.js/lib/common';
// Markdown配置(带代码高亮)
const md = new MarkdownIt({
html: true,
linkify: true,
highlight: (str, lang) => {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, {
language: lang,
ignoreIllegals: true
}).value}</code></pre>`
} catch (__) {}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
})
// 响应式数据
const messages = reactive([])
const inputText = ref('')
const loading = ref(false)
const inputEl = ref(null)
const autoScroll = ref(true)
const isStreaming = ref(false)
const abortController = ref<AbortController | null>(null)
// 保存消息容器的引用
const messagesContainer = ref(null);
// 计算属性
const canSend = computed(() => {
return inputText.value.trim().length > 0 &&
!loading.value &&
inputText.value.length <= 1000
})
// 格式化时间
const formatTime = (date) => {
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// Markdown渲染方法
const renderMarkdown = (content) => {
return md.render(content)
}
const processStream = async (reader) => {
const decoder = new TextDecoder('utf-8');
let buffer = '';
const currentMsg = messages[messages.length - 1]; // 当前消息对象
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
currentMsg.status = 'completed';
break;
}
// 将二进制数据解码为字符串
buffer += decoder.decode(value, { stream: true });
// 处理可能的多个数据块
while (buffer.includes('\n')) {
const lineEnd = buffer.indexOf('\n');
const line = buffer.slice(0, lineEnd).trim(); // 去除空白字符
buffer = buffer.slice(lineEnd + 1);
if (line.startsWith('data: ')) {
const payload = line.replace('data: ', '');
// 检查是否结束
if (payload === '[DONE]') {
currentMsg.status = 'completed';
break;
}
try {
const parsed = JSON.parse(payload);
// 提取增量内容
if (parsed.choices && parsed.choices[0].delta) {
const delta = parsed.choices[0].delta;
// 更新消息内容
if (delta.content) {
currentMsg.content += delta.content;
}
if (delta.reasoning_content) {
currentMsg.content += delta.reasoning_content;
}
// 更新状态和时间戳
currentMsg.updatedAt = Date.now();
// 检查是否结束
if (parsed.choices[0].finish_reason === 'stop') {
currentMsg.status = 'completed';
break;
}
}
} catch (error) {
console.error('JSON 解析错误:', error);
currentMsg.status = 'error';
currentMsg.content += '\n(数据解析错误)';
}
}
}
// 更新 UI
await nextTick();
if (autoScroll.value) {
scrollToBottom();
}
}
} catch (error) {
console.error('流处理错误:', error);
currentMsg.status = 'error';
currentMsg.content += '\n(好的,已停下)';
} finally {
loading.value = false; // 结束加载状态
isStreaming.value = false; // 结束流式传输状态
}
};
// 滚动控制
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTo({
top: messagesContainer.value.scrollHeight,
behavior: 'smooth'
});
}
};
// 自动滚动条件判断
const handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target
autoScroll.value = scrollHeight - (scrollTop + clientHeight) < 50
}
// 发送消息
const sendMessage = async () => {
if (!canSend.value) return
const userMessage = {
id: Date.now(),
role: 'user',
content: inputText.value.trim(),
createdAt: Date.now()
}
const aiMessage = {
id: Date.now() + 1,
role: 'assistant',
content: '',
status: 'streaming',
createdAt: Date.now(),
updatedAt: Date.now()
}
messages.push(userMessage, aiMessage)
inputText.value = ''
loading.value = true
autoScroll.value = true
isStreaming.value = true
// 创建 AbortController 实例
abortController.value = new AbortController()
try {
const response = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${import.meta.env.VITE_APP_ALIYUNAI_TESTKEY}`
},
body: JSON.stringify({
model: "deepseek-r1",
messages: messages.slice(0, -1).map(msg => ({
role: msg.role,
content: msg.content
})),
max_tokens: 4096,
temperature: 0.1,
stream: true,
}),
signal: abortController.value.signal // 传递 AbortSignal
})
if (!response.ok) throw new Error(`HTTP错误 ${response.status}`)
await processStream(response.body.getReader())
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求已中止')
aiMessage.status = 'aborted'
aiMessage.content += '\n 好的,已停下。'
} else {
console.error('请求失败:', error)
aiMessage.status = 'error'
aiMessage.content = '请求失败: ' + error.message
}
} finally {
loading.value = false
isStreaming.value = false
abortController.value = null
}
}
// 中断请求
const abortRequest = () => {
if (abortController.value) {
abortController.value.abort()
isStreaming.value = false
}
}
// 其他交互逻辑
const handleShiftEnter = () => {
inputText.value += '\n'
autoResizeTextarea()
}
const autoResizeTextarea = () => {
nextTick(() => {
const textarea = inputEl.value;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
});
};
const retryMessage = (msg) => {
const index = messages.indexOf(msg)
if (index !== -1) {
messages.splice(index, 1)
inputText.value = messages[index - 1].content
sendMessage()
}
}
// 生命周期钩子
onMounted(() => {
autoResizeTextarea()
scrollToBottom()
})
</script>
<style lang="scss" scoped>
.advanced-chat-container {
max-width: 1200px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
background: #f8f9fa;
}
.messages-wrapper {
flex: 1;
overflow-y: auto;
padding: 20px;
background: linear-gradient(to bottom, #ffffff, #f5f7fb);
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
}
&::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
}
.message-item {
color: #060607;
font-size: 14px;
margin: 12px 0;
transition: all 0.3s ease;
&.message-fade-enter-from {
opacity: 0;
transform: translateY(20px);
}
}
.message-ai{
display: flex;
align-items: flex-start;
margin-left: 48px;
position: relative;
transition: all 0.3s ease;
&:before {
content: '';
position: absolute;
left: -48px;
top: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: #f5f5f5;
transition: all 0.3s ease;
background-image: url(@/assets/logo/logo.png); /* 替换为你的图片URL */
background-size: cover; /* 确保图片覆盖整个圆形区域 */
background-position: center; /* 图片居中显示 */
}
}
.message-bubble {
max-width: 75%;
padding: 16px;
border-radius: 12px;
position: relative;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
&.user {
background: #007bff;
color: white;
margin-left: auto;
.timestamp {
color: rgba(255,255,255,0.7);
}
}
&.ai {
background: white;
border: 1px solid #e0e0e0;
.markdown-content {
:deep(pre) {
background: #1e1e1e;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
:deep(code) {
font-family: 'Fira Code', monospace;
font-size: 0.9em;
}
}
.streaming-cursor {
display: inline-block;
width: 8px;
height: 1em;
background: #666;
margin-left: 4px;
animation: blink 1s infinite;
}
}
}
.status-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
font-size: 0.8em;
color: #666;
.retry-btn {
color: #007bff;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
&:hover {
background: rgba(0,123,255,0.1);
}
}
}
@keyframes blink {
50% { opacity: 0 }
}
.enhanced-input-area {
background: white;
padding: 16px;
box-shadow: 0 -4px 12px rgba(0,0,0,0.05);
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
textarea {
flex: 1;
min-height: 48px;
max-height: 200px;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
resize: none;
font-family: inherit;
line-height: 1.5;
&:focus {
border-color: #007bff;
outline: none;
box-shadow: 0 0 0 2px rgba(0,123,255,0.2);
}
}
.send-btn {
height: 48px;
width: 80px;
border: none;
border-radius: 8px;
background: #007bff;
color: white;
cursor: pointer;
transition: all 0.2s;
&:disabled {
background: #ccc;
cursor: not-allowed;
}
&.loading {
background: #007bffaa;
}
}
.abort-btn {
height: 48px;
width: 80px;
border: none;
border-radius: 8px;
background: #ff4d4f;
color: white;
cursor: pointer;
transition: all 0.2s;
&:disabled {
background: #ccc;
cursor: not-allowed;
}
}
}
.input-footer {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 0.9em;
color: #666;
.char-count {
margin-left: auto;
color: #999;
}
}
}
.loading-indicator {
display: flex;
align-items: center;
font-size: 12px;
gap: 8px;
color: #666;
padding: 12px;
margin-left: 60px;
padding-top:0px ;
.spinner {
width: 12px;
height: 12px;
border: 2px solid #ddd;
border-top-color: #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg) }
}
</style>
更多推荐
所有评论(0)