当前位置: 首页 > news >正文

vue3 + thinkphp 接入 七牛云 DeepSeek-R1/V3 流式调用和非流式调用

如何获取七牛云 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 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">
          <!-- 使用v-html渲染解析后的Markdown内容 -->
          <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="loading-indicator">
        <div class="dot-flashing"></div>
      </div>
    </div>

    <div class="input-area">
      <textarea v-model="inputText" maxlength="9999" ref="inputRef" @keydown.enter.exact.prevent="sendMessage"
        placeholder="输入你的问题..." :disabled="isLoading"></textarea>
      <div class="input-icons">
        <button @click="sendMessage" :disabled="isLoading || !inputText.trim()" class="send-button">
          {{ isLoading ? '生成中...' : '发送' }}
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick, Ref } from 'vue'
// import { qnDeepseekChat } from '@/api/qnDeepseek'
import MarkdownIt from 'markdown-it'
import 'github-markdown-css'

interface ChatMessage {
  role: 'user' | 'assistant'
  content: string
  isStreaming?: boolean
}

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) // 回复是否结束

// 滚动到底部
const scrollToBottom = () => {
  nextTick(() => {
    if (messagesContainer.value) {
      messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
    }
  })
}

// 流式接收处理
const processStreamResponse = async (userMessage: any) => {
  const aiMessage: ChatMessage = {
    role: 'assistant',
    content: '',
    isStreaming: true
  };
  stopReceived.value = false;
  messages.value.push(aiMessage);
  const eventSource = 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.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));
      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.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.close();
      }
    } catch (error) {
      console.error('Parse error:', error);
    }
  };
  eventSource.onerror = (e: Event) => {
    console.error('EventSource failed:', e);
    isLoading.value = false;
    aiMessage.content = md.render(buffer) + '\n[模型服务过载,请稍后再试.]';
    aiMessage.isStreaming = false;
    messages.value[messages.value.length - 1] = { ...aiMessage };
    scrollToBottom()
    eventSource.close();
  };
};

// 流式调用
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 {
    await processStreamResponse(userMessage); // 启动流式处理
  } catch (error) {
    console.error('Error:', error);
    messages.value.push({
      role: 'assistant',
      content: '⚠️ 请求失败,请稍后再试'
    });
  } finally {
    scrollToBottom();
  }
};

// 非流式调用
// 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;
  /* resize: none; */
  height: 100%;
  max-height: 180px;
  /* Maximum height of 6 rows */
  background-color: #f1f1f1;
  font-size: 14px;
}

/* 移除获取焦点时的边框 */
textarea:focus {
  outline: none;
  /* 移除outline */
  border: 1px solid #ddd;
  /* 保持原边框样式,不改变 */
}

.input-icons {
  display: flex;
  align-items: center;
}

.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;
}

.loading-indicator {
  padding: 12px;
  text-align: center;
}

.dot-flashing {
  position: relative;
  width: 10px;
  height: 10px;
  border-radius: 5px;
  background-color: #42b983;
  animation: dotFlashing 1s infinite linear alternate;
}

@keyframes dotFlashing {
  0% {
    opacity: 0.2;
    transform: translateY(0);
  }

  50% {
    opacity: 1;
    transform: translateY(-5px);
  }

  100% {
    opacity: 0.2;
    transform: translateY(0);
  }
}

@keyframes blink {
  50% {
    opacity: 0;
  }
}
</style>

相关文章:

  • 怎麼防止爬蟲IP被網站封鎖?
  • rustdesk编译修改名字
  • JavaScript系列(76)--浏览器API深入
  • Ubuntu学习备忘
  • 在本地成功部署 AlphaFold 3:完整指南
  • 数据库提权总结
  • 机器学习入门实战 1 - 认识机器学习
  • 网络安全推荐的视频教程 网络安全系列
  • Vue 项目中逐步引入 TypeScript 的类型检查
  • 什么是全零监听?为什么要全零监听?如何修改ollama配置实现全零监听?风险是什么?怎么应对?
  • 【Prometheus】prometheus结合pushgateway实现脚本运行状态监控
  • 3.1 Hugging Face Transformers快速入门:零基础到企业级开发的实战指南
  • SpringCloud面试题----eureka和zookeeper都可以提供服务注册与发现的功能,请说说两个的区别
  • 数智读书笔记系列014 MICK《SQL进阶教程》第一版和第二版对比和总结
  • React 与 Vue 对比指南 - 上
  • vue脚手架开发打地鼠游戏
  • 用Python+SACS玩转悬臂梁建模:从零开始的结构分析实战
  • 4.如何处理Labelme标注后的数据
  • 基于 Cookie 追踪用户行为
  • 利用分治策略优化快速排序
  • 威尼斯建筑双年展总策划:山的另一边有什么在等着我们
  • 牛市早报|中美日内瓦经贸会谈联合声明公布
  • 国务院新闻办公室发布《新时代的中国国家安全》白皮书
  • 学习时报头版:世界要公道不要霸道
  • 专访|日本驻华大使金杉宪治:对美、对华外交必须在保持平衡的基础上稳步推进
  • 西甲上海足球学院揭幕,用“足球方法论”试水中国青训