如何获取七牛云 Token API 密钥
https://eastern-squash-d44.notion.site/Token-API-1932c3f43aee80fa8bfafeb25f1163d8
后端
// 七牛云 DeepSeek API 地址
private $deepseekUrl = 'https://api.qnaigc.com/v1/chat/completions';
private $deepseekKey = '秘钥';
// 流式调用
public function qnDSchat()
{
// 禁用所有缓冲
while (ob_get_level()) ob_end_clean();
// 设置流式响应头(必须最先执行)
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache, must-revalidate');
header('X-Accel-Buffering: no'); // 禁用Nginx缓冲
header('Access-Control-Allow-Origin: *');
// 获取用户输入
$userMessage = input('get.content');
// 构造API请求数据
$data = [
'model' => 'deepseek-v3', // 支持模型:"deepseek-r1"和"deepseek-v3"
'messages' => [['role' => 'user', 'content' => $userMessage]],
'stream' => true, // 启用流式响应
'temperature' => 0.7
];
// 初始化 cURL
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->deepseekUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->deepseekKey,
'Content-Type: application/json',
'Accept: text/event-stream'
],
CURLOPT_WRITEFUNCTION => function($ch, $data) {
// 解析七牛云返回的数据结构
$lines = explode("\n", $data);
foreach ($lines as $line) {
if (strpos($line, 'data: ') === 0) {
$payload = json_decode(substr($line, 6), true);
$content = $payload['choices'][0]['delta']['content'] ?? '';
// 按SSE格式输出
echo "data: " . json_encode([
'content' => $content,
'finish_reason' => $payload['choices'][0]['finish_reason'] ?? null
]) . "\n\n";
ob_flush();
flush();
}
}
return strlen($data);
},
CURLOPT_RETURNTRANSFER => false,
CURLOPT_TIMEOUT => 120
]);
// 执行请求
curl_exec($ch);
curl_close($ch);
exit();
}
// 非流式调用
public function qnDSchat2()
{
$userMessage = input('post.content');
// 构造API请求数据
$data = [
'model' => 'deepseek-v3', // 支持模型:"deepseek-r1"和"deepseek-v3"
'messages' => [['role' => 'user', 'content' => $userMessage]],
'temperature' => 0.7
];
// 发起API请求
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->deepseekUrl,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->deepseekKey,
'Content-Type: application/json'
],
CURLOPT_RETURNTRANSFER => true, // 获取返回结果
CURLOPT_TIMEOUT => 120
]);
// 执行请求并获取返回数据
$response = curl_exec($ch);
curl_close($ch);
// 解析API返回结果
$responseData = json_decode($response, true);
// 根据实际的API响应格式返回数据
return json([
'content' => $responseData['choices'][0]['message']['content'] ?? '没有返回内容',
'finish_reason' => $responseData['choices'][0]['finish_reason'] ?? null
]);
}
前端
npm i markdown-it github-markdown-css
<template>
<div class="chat-container">
<div class="messages" ref="messagesContainer">
<div class="default-questions">
<div v-for="(question, index) in defaultQuestions" :key="index" @click="handleQuestionClick(question)"
class="default-question">
{{ question }}
</div>
</div>
<div v-for="(message, index) in messages" :key="index" class="message"
:class="{ 'user-message': message.role === 'user', 'ai-message': message.role === 'assistant' }">
<div class="message-content">
<!-- <span v-if="message.role === 'assistant' && message.isStreaming"></span> -->
<div v-if="message.role === 'assistant'" v-html="message.content" class="markdown-body"></div>
<div v-if="message.role === 'user'" v-text="message.content"></div>
</div>
</div>
<div v-if="isLoading" class="orbit-spinner">
<div class="orbit"></div>
<div class="orbit"></div>
<div class="orbit"></div>
</div>
</div>
<div class="input-area">
<textarea v-model="inputText" maxlength="9999" ref="inputRef"
@keydown.enter.exact.prevent="sendMessage(inputText.trim())" placeholder="输入你的问题..."
:disabled="isLoading"></textarea>
<div class="input-icons">
<button @click="sendMessage(inputText.trim())" :disabled="isLoading || !inputText.trim()" class="send-button">
{{ isLoading ? '生成中...' : '发送' }}
</button>
<button @click="stopMessage" :disabled="!isLoading" class="stop-button">
停止
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, Ref, onMounted, onBeforeUnmount } from 'vue'
import MarkdownIt from 'markdown-it'
import 'github-markdown-css'
// import { marked } from 'marked';
interface ChatMessage {
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
}
const eventSource: Ref = ref(null)
const messages = ref<ChatMessage[]>([])
const inputText = ref('')
const isLoading = ref(false)
const messagesContainer = ref<HTMLElement | null>(null)
const inputRef: Ref = ref(null)
const stopReceived: Ref = ref(true)
let aiMessage: ChatMessage = {
role: 'assistant',
content: '',
isStreaming: true
};
const defaultQuestions = ref([
"中医有哪些治疗方法?",
"中医有哪些经典著作?",
"中医有哪些传统方剂?",
"中医有哪些养生方法?",
])
onMounted(() => {
setTimeout(() => {
inputRef.value?.focus()
}, 1000)
})
onBeforeUnmount(() => {
stopMessage();
});
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
const stopMessage = () => {
stopReceived.value = true
if (eventSource.value) {
eventSource.value.close();
}
}
// 流式接收处理
const processStreamResponse = async (userMessage: any) => {
aiMessage = {
role: 'assistant',
content: '',
isStreaming: true
};
stopReceived.value = false;
messages.value.push(aiMessage);
eventSource.value = new EventSource(`https://api.ecom20200909.com/Other/qnDSchat?content=${encodeURIComponent(userMessage)}`);
let buffer = '';
let index = 0;
const md = new MarkdownIt();
const typeWriter = () => {
if (stopReceived.value) {
// 如果接收数据完成,则不用打字机形式一点点显示,而是把剩余数据全部显示完
aiMessage.content = md.render(buffer); // 渲染剩余的所有内容
// aiMessage.content = marked(buffer);
aiMessage.isStreaming = false;
messages.value[messages.value.length - 1] = { ...aiMessage };
isLoading.value = false;
nextTick(() => {
inputRef.value?.focus();
});
scrollToBottom();
return
}
// 确保不会超出buffer的长度
const toRenderLength = Math.min(index + 1, buffer.length);
if (index < buffer.length) {
aiMessage.content = md.render(buffer.substring(0, toRenderLength));
// aiMessage.content = marked(buffer.substring(0, toRenderLength));
messages.value[messages.value.length - 1] = { ...aiMessage };
index = toRenderLength; // 更新index为实际处理的长度
setTimeout(typeWriter, 30); // 控制打字速度,30ms显示最多1个字符
scrollToBottom()
} else {
// 超过几秒没有新数据,重新检查index
setTimeout(() => {
if (!stopReceived.value || index < buffer.length) {
typeWriter(); // 如果还没有收到停止信号并且还有未处理的数据,则继续处理
} else {
aiMessage.isStreaming = false;
messages.value[messages.value.length - 1] = { ...aiMessage };
isLoading.value = false;
nextTick(() => {
inputRef.value?.focus();
});
scrollToBottom();
}
}, 2000);
}
};
eventSource.value.onmessage = (e: MessageEvent) => {
try {
const data = JSON.parse(e.data);
const newContent = data.choices[0].delta.content;
if (newContent) {
buffer += newContent; // 将新内容添加到缓冲区
if (index === 0) {
typeWriter();
}
}
if (data.choices[0].finish_reason === 'stop') {
stopReceived.value = true;
eventSource.value.close();
}
} catch (error) {
console.error('Parse error:', error);
}
};
eventSource.value.onerror = (e: Event) => {
console.error('EventSource failed:', e);
isLoading.value = false;
aiMessage.content = md.render(buffer) + '\n[模型服务过载,请稍后再试.]';
// aiMessage.content = marked(buffer) + '\n[模型服务过载,请稍后再试.]';
aiMessage.isStreaming = false;
messages.value[messages.value.length - 1] = { ...aiMessage };
scrollToBottom()
eventSource.value.close();
};
};
// 流式调用
const sendMessage = async (question?: any) => {
let userMessage = question || inputText.value.trim();
if (!userMessage || isLoading.value) return;
inputText.value = '';
messages.value.push({
role: 'user',
content: userMessage
});
isLoading.value = true;
scrollToBottom();
try {
await processStreamResponse(userMessage);
} catch (error) {
console.error('Error:', error);
messages.value.push({
role: 'assistant',
content: '⚠️ 请求失败,请稍后再试'
});
isLoading.value = false;
nextTick(() => {
inputRef.value?.focus();
});
} finally {
scrollToBottom();
}
};
const handleQuestionClick = (question: string) => {
sendMessage(question);
}
// 非流式调用
// const sendMessage = async () => {
// if (!inputText.value.trim() || isLoading.value) return
// const userMessage = inputText.value.trim()
// inputText.value = ''
// // 添加用户消息
// messages.value.push({
// role: 'user',
// content: userMessage
// })
// isLoading.value = true
// scrollToBottom()
// try {
// // 调用后端接口
// const response = await qnDeepseekChat(userMessage)
// // 解析 AI 的回复并添加到消息中
// const md = new MarkdownIt();
// const markdownContent = response.content || '没有返回内容';
// const htmlContent = md.render(markdownContent);
// messages.value.push({
// role: 'assistant',
// content: htmlContent
// })
// } catch (error) {
// messages.value.push({
// role: 'assistant',
// content: '⚠️ 请求失败,请稍后再试'
// })
// } finally {
// isLoading.value = false
// nextTick(() => {
// inputRef.value?.focus()
// })
// scrollToBottom()
// }
// }
</script>
<style scoped>
.chat-container {
max-width: 800px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f5f5f5;
}
.message {
margin-bottom: 20px;
}
.message-content {
max-width: 100%;
padding: 12px 20px;
border-radius: 12px;
display: inline-block;
position: relative;
font-size: 16px;
}
.user-message {
text-align: right;
}
.user-message .message-content {
background: #42b983;
color: white;
margin-left: auto;
}
.ai-message .message-content {
background: white;
border: 1px solid #ddd;
}
.input-area {
padding: 12px 20px;
background: #f1f1f1;
border-top: 1px solid #ddd;
display: flex;
gap: 10px;
align-items: center;
min-height: 100px;
}
textarea {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 20px;
height: 100%;
max-height: 180px;
background-color: #f1f1f1;
font-size: 14px;
}
textarea:focus {
outline: none;
border: 1px solid #ddd;
}
.input-icons {
display: flex;
align-items: center;
flex-direction: column;
}
.send-button {
padding: 8px 16px;
background: #42b983;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
transition: opacity 0.2s;
font-size: 14px;
}
.send-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.stop-button {
padding: 8px 16px;
background: #b94a42;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
transition: opacity 0.2s;
font-size: 14px;
margin-top: 5px;
}
.stop-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.default-questions {
padding: 10px;
margin-bottom: 10px;
background-color: #f0f0f0;
border-radius: 8px;
}
.default-question {
padding: 8px;
margin: 4px;
cursor: pointer;
background-color: #fff;
border-radius: 5px;
transition: background-color .3s ease;
}
.default-question:hover {
background-color: #e0e0e0;
}
.orbit-spinner,
.orbit-spinner * {
box-sizing: border-box;
}
.orbit-spinner {
height: 55px;
width: 55px;
border-radius: 50%;
perspective: 800px;
}
.orbit-spinner .orbit {
position: absolute;
box-sizing: border-box;
width: 100%;
height: 100%;
border-radius: 50%;
}
.orbit-spinner .orbit:nth-child(1) {
left: 0%;
top: 0%;
animation: orbit-spinner-orbit-one-animation 1200ms linear infinite;
border-bottom: 3px solid #ff1d5e;
}
.orbit-spinner .orbit:nth-child(2) {
right: 0%;
top: 0%;
animation: orbit-spinner-orbit-two-animation 1200ms linear infinite;
border-right: 3px solid #ff1d5e;
}
.orbit-spinner .orbit:nth-child(3) {
right: 0%;
bottom: 0%;
animation: orbit-spinner-orbit-three-animation 1200ms linear infinite;
border-top: 3px solid #ff1d5e;
}
@keyframes orbit-spinner-orbit-one-animation {
0% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(360deg);
}
}
@keyframes orbit-spinner-orbit-two-animation {
0% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(0deg);
}
100% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(360deg);
}
}
@keyframes orbit-spinner-orbit-three-animation {
0% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(360deg);
}
}
::v-deep .markdown-body h1,
::v-deep .markdown-body h2,
::v-deep .markdown-body h3,
::v-deep .markdown-body h4,
::v-deep .markdown-body h5,
::v-deep .markdown-body h6 {
margin: 0 !important;
}
::v-deep .markdown-body p,
::v-deep .markdown-body blockquote,
::v-deep .markdown-body ul,
::v-deep .markdown-body ol,
::v-deep .markdown-body dl,
::v-deep .markdown-body table,
::v-deep .markdown-body pre,
::v-deep .markdown-body details {
margin: 0 !important;
}
</style>