Commit 365ac9c9 by zhangxingmin

ai

parent 58a41c40
...@@ -20,17 +20,23 @@ ...@@ -20,17 +20,23 @@
"@element-plus/icons-vue": "2.3.1", "@element-plus/icons-vue": "2.3.1",
"@vueup/vue-quill": "1.2.0", "@vueup/vue-quill": "1.2.0",
"@vueuse/core": "13.3.0", "@vueuse/core": "13.3.0",
"ali-oss": "^6.23.0",
"axios": "1.9.0", "axios": "1.9.0",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dompurify": "^3.3.3",
"echarts": "5.6.0", "echarts": "5.6.0",
"element-plus": "2.9.9", "element-plus": "2.9.9",
"file-saver": "2.0.5", "file-saver": "^2.0.5",
"fuse.js": "6.6.2", "fuse.js": "6.6.2",
"highlight.js": "^11.11.1",
"js-beautify": "1.14.11", "js-beautify": "1.14.11",
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"jsencrypt": "3.3.2", "jsencrypt": "3.3.2",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^4.3.0",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"p-limit": "^7.3.0",
"pinia": "3.0.2", "pinia": "3.0.2",
"splitpanes": "^4.0.4", "splitpanes": "^4.0.4",
"vue": "3.5.16", "vue": "3.5.16",
......
// src/api/ai/ai.js
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import useUserStore from '@/store/modules/user'
// 加载随机词条列表
export function randList(data) {
return request({
url: '/ai/api/entry/rand/list',
method: 'post',
data: data
})
}
/**
* 查询输出流信息(流式响应)
* @param {string} question 用户问题
* @param {number} timeout 超时时间(毫秒),默认 300000
* @returns {Promise<Response>} fetch 响应对象
*/
export function getStream(question, timeout = 300000) {
const userStore = useUserStore()
const token = getToken()
const tenantId = userStore.currentTenant?.apiLoginTenantInfoResponse?.tenantBizId
const headers = {
'Accept': 'text/event-stream',
'Authorization': `Bearer ${token}`
}
if (tenantId) {
headers['X-Tenant-ID'] = tenantId
}
// 从环境变量获取基础 URL
const baseUrl = import.meta.env.VITE_APP_BASE_API
// 拼接完整 URL(注意:后端实际路径为 /ai/api/ai/stream)
const url = `${baseUrl}/ai/api/api/ai/stream?question=${encodeURIComponent(question)}`
// 使用 AbortController 实现超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
return fetch(url, {
method: 'GET',
headers,
signal: controller.signal
}).finally(() => {
clearTimeout(timeoutId)
})
}
\ No newline at end of file
...@@ -37,6 +37,22 @@ export const constantRoutes = [ ...@@ -37,6 +37,22 @@ export const constantRoutes = [
} }
] ]
}, },
// {
// path: '/system',
// component: Layout,
// children: [
// {
// path: 'ai',
// name: 'Ai',
// component: Ai,
// meta: {
// title: '银盾AI',
// icon: 'message' // 【关键修改】补充图标名,避免 undefined
// // 如果项目里有其他 svg 图标,换成对应的文件名(不含.svg)
// }
// }
// ]
// },
{ {
path: '/login', path: '/login',
component: () => import('@/views/login'), component: () => import('@/views/login'),
......
<template>
<div class="ai-container">
<div class="chat-header">
<div class="header-content">
<div class="logo-area">
<svg class="logo-icon" viewBox="0 0 32 32" fill="none">
<path d="M16 2L4 9v14l12 7 12-7V9L16 2z" stroke="#6c5ce7" stroke-width="1.5" fill="none"/>
<path d="M16 2v30M4 9l12 7 12-7" stroke="#6c5ce7" stroke-width="1.5" fill="none"/>
</svg>
<span class="logo-text">银盾AI</span>
</div>
<div class="header-actions">
<button class="action-btn" @click="clearChat" title="清空对话">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
<div class="chat-messages" ref="messagesContainer">
<div v-for="(msg, idx) in messages" :key="idx" :class="['message', msg.role]">
<div class="message-avatar">
<div v-if="msg.role === 'user'" class="user-avatar">U</div>
<div v-else class="ai-avatar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10 10 10 0 0 0-10-10z"/>
<path d="M12 6v4M12 16h.01"/>
</svg>
</div>
</div>
<div class="message-content" v-html="formatMessage(msg.content)"></div>
</div>
<div v-if="isLoading" class="message assistant">
<div class="message-avatar">
<div class="ai-avatar typing">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10 10 10 0 0 0-10-10z"/>
<path d="M12 6v4M12 16h.01"/>
</svg>
</div>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
<div ref="messagesEnd"></div>
</div>
<div class="chat-input-area">
<div class="suggestions-area" v-if="suggestions.length || suggestionsLoading">
<div class="suggestions-header">
<span class="suggestions-title">💡 你可能想问:</span>
<button class="refresh-suggestions" @click="refreshSuggestions" :disabled="suggestionsLoading">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 2v6h-6M4 12a8 8 0 0 1 16 0 8 8 0 0 1-8 8 8 8 0 0 1-8-8z"/>
<path d="M12 8v4l3 3"/>
</svg>
换一批
</button>
</div>
<div class="suggestions-list">
<div v-if="suggestionsLoading" class="suggestions-loading">
<span class="loading-dot"></span><span class="loading-dot"></span><span class="loading-dot"></span>
</div>
<template v-else>
<button
v-for="(item, idx) in suggestions"
:key="idx"
class="suggestion-chip"
@click="handleSuggestionClick(item.content)"
:disabled="isLoading"
>
{{ item.content }}
</button>
<div v-if="!suggestions.length && !suggestionsLoading" class="suggestions-empty">
暂无推荐,点击换一批
</div>
</template>
</div>
</div>
<div class="input-wrapper">
<textarea
v-model="question"
:disabled="isLoading"
placeholder="请输入问题..."
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.shift.exact="question += '\n'"
rows="1"
ref="inputTextarea"
></textarea>
<button class="send-btn" @click="sendMessage" :disabled="isLoading || !question.trim()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<div class="input-hint">Shift + Enter 换行</div>
</div>
</div>
</template>
<script>
import { randList, getStream } from '@/api/ai/ai'
import { marked } from 'marked'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
// 配置 marked,增强安全性
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
breaks: true,
gfm: true,
mangle: false, // 禁用 HTML 实体编码,防止 XSS
headerIds: false, // 不自动生成标题 id
})
export default {
name: 'Ai',
data() {
return {
question: '',
messages: [],
isLoading: false,
suggestions: [],
suggestionsLoading: false,
}
},
mounted() {
this.messages.push({
role: 'assistant',
content: '你好!我是 银盾AI助手,有什么可以帮你的吗?'
})
this.loadSuggestions()
},
methods: {
// 滚动到底部
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer
if (container) {
container.scrollTop = container.scrollHeight
}
})
},
// 加载随机词条
async loadSuggestions(randNum = 4) {
this.suggestionsLoading = true
try {
const response = await randList({ randNum })
if (response.code === 200 && Array.isArray(response.data)) {
this.suggestions = response.data.map(item => ({
content: item.content,
id: item.id,
}))
} else {
console.warn('词条接口返回异常:', response)
this.suggestions = []
}
} catch (error) {
console.error('加载词条失败:', error)
this.suggestions = []
} finally {
this.suggestionsLoading = false
}
},
// 换一批
refreshSuggestions() {
if (!this.suggestionsLoading) {
this.loadSuggestions(4)
}
},
// 点击词条
handleSuggestionClick(content) {
if (this.isLoading) return
if (content && content.trim()) {
this.question = content.trim()
this.sendMessage()
}
},
// 格式化消息(Markdown 转 HTML)
formatMessage(content) {
if (!content) return ''
// 对用户消息内容进行额外转义(防止恶意 HTML)
// 但 marked 已做安全处理,此步可省略,保留原样
return marked.parse(content)
},
async sendMessage() {
if (!this.question.trim() || this.isLoading) return
const userQuestion = this.question.trim()
this.messages.push({ role: 'user', content: userQuestion })
this.question = ''
this.isLoading = true
this.scrollToBottom() // 立即滚动显示用户消息
const assistantMessageIndex = this.messages.length
this.messages.push({ role: 'assistant', content: '' })
try {
const response = await getStream(userQuestion)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
let accumulatedContent = ''
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() || ''
for (let line of lines) {
line = line.trim()
if (line === '') continue
let content = ''
if (line.startsWith('data:')) {
content = line.slice(5).trim()
if (content === '[DONE]') continue
} else if (line.startsWith('message:')) {
content = line.slice(8).trim()
} else if (line.startsWith('event:')) {
// 忽略事件类型行
continue
} else {
content = line
}
if (content) {
accumulatedContent += content
this.messages[assistantMessageIndex].content = accumulatedContent
this.scrollToBottom() // 每次更新内容后滚动
}
}
}
// 处理剩余 buffer
if (buffer.trim()) {
let content = buffer
if (buffer.startsWith('data:')) content = buffer.slice(5).trim()
else if (buffer.startsWith('message:')) content = buffer.slice(8).trim()
if (content && content !== '[DONE]') {
accumulatedContent += content
this.messages[assistantMessageIndex].content = accumulatedContent
this.scrollToBottom()
}
}
} catch (error) {
console.error('流式请求失败:', error)
// 处理超时或中断错误
const errorMsg = error.name === 'AbortError' ? '请求超时,请稍后重试' : `错误:${error.message}`
this.messages[assistantMessageIndex].content = `❌ ${errorMsg}`
this.scrollToBottom()
} finally {
this.isLoading = false
}
},
clearChat() {
this.messages = []
this.messages.push({
role: 'assistant',
content: '对话已清空,有什么可以帮你的吗?'
})
this.scrollToBottom()
}
}
}
</script>
<style scoped>
.ai-container {
display: flex;
flex-direction: column;
height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #f0f2f5 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.chat-header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 10;
}
.header-content {
max-width: 900px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
}
.logo-area {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 28px;
height: 28px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
background: linear-gradient(135deg, #6c5ce7, #a855f7);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.action-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 8px;
color: #666;
transition: all 0.2s;
}
.action-btn:hover {
background: #f0f0f0;
color: #6c5ce7;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.message {
display: flex;
gap: 12px;
margin-bottom: 24px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message.user {
flex-direction: row-reverse;
}
.message-avatar {
flex-shrink: 0;
}
.user-avatar {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
}
.ai-avatar {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #6c5ce7, #a855f7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 18px;
line-height: 1.6;
font-size: 15px;
}
.message.user .message-content {
background: linear-gradient(135deg, #6c5ce7, #7c3aed);
color: white;
border-bottom-right-radius: 4px;
}
.message.assistant .message-content {
background: white;
color: #2d2d2d;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.chat-input-area {
background: white;
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding: 16px 24px;
position: sticky;
bottom: 0;
}
.suggestions-area {
max-width: 900px;
margin: 0 auto 12px auto;
background: #f8f9fa;
border-radius: 16px;
padding: 12px 16px;
border: 1px solid #e9ecef;
}
.suggestions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
color: #6c757d;
}
.suggestions-title {
font-weight: 500;
}
.refresh-suggestions {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
color: #6c5ce7;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 20px;
transition: all 0.2s;
}
.refresh-suggestions:hover:not(:disabled) {
background: rgba(108, 92, 231, 0.1);
}
.refresh-suggestions:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.suggestions-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
min-height: 36px;
}
.suggestion-chip {
background: white;
border: 1px solid #e2e8f0;
border-radius: 24px;
padding: 6px 14px;
font-size: 13px;
color: #2d3748;
cursor: pointer;
transition: all 0.2s;
max-width: 260px;
white-space: normal;
word-break: break-word;
text-align: left;
line-height: 1.4;
}
.suggestion-chip:hover:not(:disabled) {
background: #6c5ce7;
color: white;
border-color: #6c5ce7;
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(108, 92, 231, 0.2);
}
.suggestion-chip:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.suggestions-loading {
display: flex;
gap: 6px;
padding: 6px 0;
}
.loading-dot {
width: 6px;
height: 6px;
background: #6c5ce7;
border-radius: 50%;
animation: pulse 1.4s infinite ease-in-out;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.suggestions-empty {
font-size: 12px;
color: #94a3b8;
padding: 6px 0;
}
.input-wrapper {
max-width: 900px;
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 12px;
background: #f8f9fa;
border-radius: 24px;
padding: 8px 16px;
border: 1px solid #e9ecef;
transition: all 0.2s;
}
.input-wrapper:focus-within {
border-color: #6c5ce7;
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.1);
}
.input-wrapper textarea {
flex: 1;
border: none;
background: transparent;
padding: 8px 0;
font-size: 15px;
resize: none;
outline: none;
font-family: inherit;
max-height: 120px;
line-height: 1.5;
}
.send-btn {
background: #6c5ce7;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
transition: all 0.2s;
flex-shrink: 0;
}
.send-btn:hover:not(:disabled) {
background: #5b4cc4;
transform: scale(1.02);
}
.send-btn:disabled {
background: #cbd5e0;
cursor: not-allowed;
}
.input-hint {
max-width: 900px;
margin: 8px auto 0;
font-size: 12px;
color: #94a3b8;
text-align: center;
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #6c5ce7;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-8px); opacity: 1; }
}
.message-content :deep(h1) {
font-size: 1.5em;
font-weight: 600;
margin: 0.8em 0 0.4em;
color: #1a1a2e;
}
.message-content :deep(h2) {
font-size: 1.3em;
font-weight: 600;
margin: 0.7em 0 0.35em;
color: #1a1a2e;
}
.message-content :deep(h3) {
font-size: 1.1em;
font-weight: 600;
margin: 0.6em 0 0.3em;
color: #1a1a2e;
}
.message-content :deep(p) {
margin: 0.6em 0;
}
.message-content :deep(code) {
background-color: #f1f3f5;
padding: 0.2em 0.4em;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.9em;
color: #e83e8c;
}
.message-content :deep(pre) {
background-color: #1e1e1e;
padding: 16px;
border-radius: 12px;
overflow-x: auto;
margin: 0.8em 0;
}
.message-content :deep(pre code) {
background-color: transparent;
padding: 0;
color: #d4d4d4;
font-size: 13px;
line-height: 1.5;
}
.message-content :deep(ul), .message-content :deep(ol) {
padding-left: 1.5em;
margin: 0.6em 0;
}
.message-content :deep(li) {
margin: 0.25em 0;
}
.message-content :deep(blockquote) {
border-left: 3px solid #6c5ce7;
padding-left: 1em;
margin: 0.8em 0;
color: #6c757d;
font-style: italic;
}
.message-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
font-size: 14px;
}
.message-content :deep(th), .message-content :deep(td) {
border: 1px solid #dee2e6;
padding: 8px 12px;
text-align: left;
}
.message-content :deep(th) {
background-color: #f8f9fa;
font-weight: 600;
}
.message-content :deep(a) {
color: #6c5ce7;
text-decoration: none;
}
.message-content :deep(a:hover) {
text-decoration: underline;
}
.message-content :deep(hr) {
border: none;
border-top: 1px solid #e9ecef;
margin: 1em 0;
}
.message.user .message-content :deep(code) {
background-color: rgba(255, 255, 255, 0.2);
color: #ffe0b5;
}
.message.user .message-content :deep(pre) {
background-color: rgba(0, 0, 0, 0.3);
}
.message.user .message-content :deep(a) {
color: #ffe0b5;
}
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #cbd5e0;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment