This commit is contained in:
hrx 2026-04-21 09:37:08 +08:00
parent e57767d3b9
commit 72ef0b177b
9 changed files with 564 additions and 13 deletions

View File

@ -0,0 +1,70 @@
const AI_CHAT_URL = 'https://dev.ncmatch.cn/api/ai-chat/chat/stream/'
// AI 对话
export const reqAIChat = (data, onMessage) => {
return fetch(AI_CHAT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
}).then(async(response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
if (!response.body) {
throw new Error('当前浏览器不支持流式响应')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let fullResponse = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
lines.forEach((line) => {
if (!line.startsWith('data: ')) {
return
}
try {
const parsed = JSON.parse(line.slice(6))
if (parsed && parsed.content) {
fullResponse += parsed.content
if (typeof onMessage === 'function') {
onMessage(fullResponse)
}
}
} catch (error) {
// Ignore incomplete stream fragments until the next chunk arrives.
}
})
}
if (buffer && buffer.startsWith('data: ')) {
try {
const parsed = JSON.parse(buffer.slice(6))
if (parsed && parsed.content) {
fullResponse += parsed.content
if (typeof onMessage === 'function') {
onMessage(fullResponse)
}
}
} catch (error) {
// Ignore invalid trailing fragments.
}
}
return {
content: fullResponse
}
})
}

View File

@ -54,6 +54,12 @@
<div class="content unicode" style="display: block;">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont">&#xe60d;</span>
<div class="name">发送</div>
<div class="code-name">&amp;#xe60d;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61b;</span>
<div class="name">复制</div>
@ -132,9 +138,9 @@
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
src: url('iconfont.woff2?t=1760930186762') format('woff2'),
url('iconfont.woff?t=1760930186762') format('woff'),
url('iconfont.ttf?t=1760930186762') format('truetype');
src: url('iconfont.woff2?t=1776735138822') format('woff2'),
url('iconfont.woff?t=1776735138822') format('woff'),
url('iconfont.ttf?t=1776735138822') format('truetype');
}
</code></pre>
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
@ -160,6 +166,15 @@
<div class="content font-class">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont icon-fasong"></span>
<div class="name">
发送
</div>
<div class="code-name">.icon-fasong
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-fuzhi"></span>
<div class="name">
@ -277,6 +292,14 @@
<div class="content symbol">
<ul class="icon_lists dib-box">
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-fasong"></use>
</svg>
<div class="name">发送</div>
<div class="code-name">#icon-fasong</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-fuzhi"></use>

View File

@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 5043107 */
src: url('iconfont.woff2?t=1760930186762') format('woff2'),
url('iconfont.woff?t=1760930186762') format('woff'),
url('iconfont.ttf?t=1760930186762') format('truetype');
src: url('iconfont.woff2?t=1776735138822') format('woff2'),
url('iconfont.woff?t=1776735138822') format('woff'),
url('iconfont.ttf?t=1776735138822') format('truetype');
}
.iconfont {
@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-fasong:before {
content: "\e60d";
}
.icon-fuzhi:before {
content: "\e61b";
}

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,13 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "12719937",
"name": "发送",
"font_class": "fasong",
"unicode": "e60d",
"unicode_decimal": 58893
},
{
"icon_id": "300409",
"name": "复制",

View File

@ -160,7 +160,74 @@
</div>
</div>
<!-- 消息中心组件 -->
<div v-show="aiDialogVisible" class="ai-chat-panel">
<div class="ai-chat-panel__header">
<div class="ai-chat-panel__title">
<span class="ai-chat-panel__logo">AI</span>
<span>有问题找开元</span>
</div>
<i class="el-icon-close ai-chat-panel__close" @click="closeAIPanel"></i>
</div>
<div ref="aiChatMessages" class="ai-chat-panel__body">
<div v-if="!aiMessages.length" class="ai-chat-panel__welcome">
<h3>Hi!</h3>
<p>我是开元智能助手可以为您解答算力服务器选型采购部署和资源配置等问题</p>
<div class="ai-chat-panel__suggestions">
<span
v-for="item in aiQuickQuestions"
:key="item"
class="ai-chat-panel__tag"
@click="useAISuggestion(item)"
>
{{ item }}
</span>
</div>
</div>
<div
v-for="(item, index) in aiMessages"
:key="index"
:class="['ai-chat__message', `is-${item.role}`]"
>
<div class="ai-chat__avatar">
{{ item.role === 'assistant' ? 'AI' : '我' }}
</div>
<div class="ai-chat__bubble">
<div
v-if="item.role === 'assistant'"
class="ai-chat__rich-text"
v-html="formatAIContent(item.content)"
></div>
<span v-else>{{ item.content }}</span>
</div>
</div>
</div>
<div class="ai-chat-panel__input-wrap">
<div class="ai-chat-panel__actions">
<span class="ai-chat-panel__new" @click="resetAIChat">新对话</span>
<span class="ai-chat__tip">Enter 发送</span>
</div>
<div class="ai-chat-panel__input">
<el-input
v-model="aiInput"
type="textarea"
:rows="2"
resize="none"
maxlength="500"
placeholder="请输入你的问题"
@keydown.native="handleAIKeydown"
/>
<el-button
class="ai-chat-panel__send"
type="text"
:loading="aiLoading"
@click="sendAIMessage"
>
<i class="iconfont icon-fasong"></i>
</el-button>
</div>
</div>
</div>
<message-center
ref="messageCenter"
:visible.sync="messageCenterVisible"
@ -180,6 +247,7 @@ import { reqApplyChannel } from "@/api/customer/channel";
import store from "@/store";
import { getHomePath } from '@/views/setting/tools'
import MessageCenter from '@/components/MessageCenter/MessageCenter.vue'
import { reqAIChat } from '@/api/AI/ai'
export default Vue.extend({
name: "TopBox",
@ -188,6 +256,16 @@ export default Vue.extend({
},
data() {
return {
aiDialogVisible: false,
aiInput: '',
aiLoading: false,
aiMessages: [],
aiQuickQuestions: [
'推荐适合训练大模型的 GPU 服务器',
'4090 和 A100 怎么选',
'有没有高性价比 8 卡方案',
'国产化算力服务器有哪些'
],
messageCenterVisible: false,
homePath: getHomePath(),
isShowKbossCharge: false,
@ -270,11 +348,134 @@ export default Vue.extend({
// AI
handleAIClick() {
this.$message.info({
message: '功能即将上线,敬请期待!',
duration: 3000,
showClose: true
});
this.aiDialogVisible = true
this.$nextTick(() => {
this.scrollAIChatToBottom()
})
},
closeAIPanel() {
this.aiDialogVisible = false
},
resetAIChat() {
this.aiMessages = []
this.aiInput = ''
},
useAISuggestion(question) {
this.sendAIMessage(question)
},
handleAIKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
this.sendAIMessage()
}
},
async sendAIMessage(quickQuestion) {
const message = (quickQuestion || this.aiInput).trim()
if (!message || this.aiLoading) return
this.aiMessages.push({
role: 'user',
content: message
})
this.aiInput = ''
const loadingMessageIndex = this.aiMessages.push({
role: 'assistant',
content: '[正在检索并生成回答...]'
}) - 1
this.aiLoading = true
this.$nextTick(() => {
this.scrollAIChatToBottom()
})
try {
const response = await reqAIChat({ message }, (content) => {
if (!content) return
this.$set(this.aiMessages, loadingMessageIndex, {
role: 'assistant',
content
})
this.$nextTick(() => {
this.scrollAIChatToBottom()
})
})
if (response && response.content) {
this.$set(this.aiMessages, loadingMessageIndex, {
role: 'assistant',
content: response.content
})
} else {
this.$set(this.aiMessages, loadingMessageIndex, {
role: 'assistant',
content: '暂时没有获取到回复,请稍后再试。'
})
}
} catch (error) {
this.$set(this.aiMessages, loadingMessageIndex, {
role: 'assistant',
content: '抱歉,当前服务繁忙,请稍后再试。'
})
} finally {
this.aiLoading = false
this.$nextTick(() => {
this.scrollAIChatToBottom()
})
}
},
escapeAIHtml(content) {
return String(content || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
},
formatAIInline(content) {
return this.escapeAIHtml(content).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
},
formatAIContent(content) {
if (!content) return ''
const normalizedContent = String(content)
.replace(/\r\n/g, '\n')
.replace(/ {2,}/g, '\n')
.replace(/\n{3,}/g, '\n\n')
const lines = normalizedContent.split('\n')
return lines.map((line) => {
const trimmed = line.trim()
if (!trimmed) {
return '<div class="ai-chat__paragraph ai-chat__paragraph--space"></div>'
}
if (/^-\s+/.test(trimmed)) {
return `<div class="ai-chat__item"><span class="ai-chat__item-dot"></span><span>${this.formatAIInline(trimmed.replace(/^-\s+/, ''))}</span></div>`
}
if (/^\*\*(.+?)\*\*$/.test(trimmed)) {
return `<div class="ai-chat__section-title">${this.formatAIInline(trimmed)}</div>`
}
return `<div class="ai-chat__paragraph">${this.formatAIInline(trimmed)}</div>`
}).join('')
},
scrollAIChatToBottom() {
const messageBox = this.$refs.aiChatMessages
if (messageBox) {
messageBox.scrollTop = messageBox.scrollHeight
}
},
//
@ -1025,4 +1226,250 @@ export default Vue.extend({
color: #1E6FFF;
}
}
::v-deep .v-model{
z-index: 1;
}
.ai-chat-panel {
position: fixed;
right: 24px;
bottom: 24px;
width: 380px;
height: 620px;
background: #fff;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
z-index: 3000;
display: flex;
flex-direction: column;
overflow: hidden;
&__header {
height: 64px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
&__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px !important;
font-weight: 600;
color: #222;
}
&__logo {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(90deg, #d66bff 0%, #7d7cff 100%);
color: #fff;
font-size: 12px !important;
}
&__close {
cursor: pointer;
color: #666;
font-size: 18px !important;
}
&__body {
flex: 1;
overflow-y: auto;
padding: 18px 16px;
background: #fff;
}
&__welcome {
h3 {
margin: 0 0 12px;
color: #5f59ff;
font-size: 22px !important;
font-weight: 700;
}
p {
margin: 0 0 20px;
font-size: 14px !important;
line-height: 1.8;
color: #333;
}
}
&__suggestions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
&__tag {
display: inline-flex;
align-items: center;
padding: 10px 14px;
border-radius: 10px;
background: #f7f7f8;
color: #333;
font-size: 14px !important;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1E6FFF;
background: #eef5ff;
}
}
&__input-wrap {
border-top: 1px solid #f0f0f0;
padding: 12px 14px 14px;
background: #fff;
}
&__actions {
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
&__new {
font-size: 13px !important;
color: #666;
cursor: pointer;
&:hover {
color: #1E6FFF;
}
}
&__input {
position: relative;
}
&__send {
position: absolute;
right: 8px;
bottom: 8px;
padding: 0;
color: #7d7cff;
}
}
::v-deep .ai-chat-panel__input {
.el-textarea__inner {
min-height: 84px !important;
padding: 12px 48px 12px 12px;
border-radius: 14px;
border: 1px solid #e5eaf3;
font-size: 14px;
line-height: 1.6;
}
}
.ai-chat {
&__message {
display: flex;
gap: 12px;
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
&.is-user {
flex-direction: row-reverse;
.ai-chat__bubble {
background: linear-gradient(90deg, #275AFF 0%, #2EBDFA 100%);
color: #fff;
}
}
}
&__avatar {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px !important;
color: #1E6FFF;
background: rgba(30, 111, 255, 0.12);
}
&__bubble {
max-width: calc(100% - 46px);
padding: 12px 14px;
border-radius: 12px;
background: #f7f9fc;
color: #333;
font-size: 14px !important;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
&__rich-text {
font-size: 14px !important;
line-height: 1.7;
}
&__paragraph {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
&--space {
height: 8px;
margin-bottom: 0;
}
}
&__section-title {
margin: 12px 0 8px;
font-weight: 700;
color: #222F60;
&:first-child {
margin-top: 0;
}
}
&__item {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
&__item-dot {
width: 6px;
height: 6px;
margin-top: 9px;
border-radius: 50%;
background: #7d7cff;
flex-shrink: 0;
}
&__tip {
color: #999;
font-size: 12px !important;
}
}
::v-deep .ai-chat__rich-text strong {
font-weight: 700;
color: #222F60;
}
</style>