518 lines
17 KiB
JavaScript
518 lines
17 KiB
JavaScript
// Vue 3 Application
|
||
const { createApp } = Vue;
|
||
|
||
createApp({
|
||
data() {
|
||
return {
|
||
messages: [],
|
||
userInput: '',
|
||
loading: false,
|
||
sessionId: null,
|
||
charts: {},
|
||
showImageModal: false,
|
||
modalImageUrl: '',
|
||
showContactModal: false,
|
||
availableModels: [],
|
||
currentModel: null,
|
||
selectedModel: null
|
||
};
|
||
},
|
||
mounted() {
|
||
// 检查登录状态
|
||
if (!this.checkAuth()) {
|
||
window.location.href = '/static/login.html';
|
||
return;
|
||
}
|
||
|
||
this.sessionId = this.generateSessionId();
|
||
this.autoResizeTextarea();
|
||
this.loadModels();
|
||
},
|
||
methods: {
|
||
checkAuth() {
|
||
const token = localStorage.getItem('token');
|
||
if (!token) return false;
|
||
|
||
// 验证token是否过期(简单检查)
|
||
try {
|
||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||
return payload.exp * 1000 > Date.now();
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
|
||
logout() {
|
||
localStorage.removeItem('token');
|
||
window.location.href = '/static/login.html';
|
||
},
|
||
|
||
async sendMessage() {
|
||
if (!this.userInput.trim() || this.loading) return;
|
||
|
||
const message = this.userInput.trim();
|
||
this.userInput = '';
|
||
|
||
// Add user message
|
||
this.messages.push({
|
||
role: 'user',
|
||
content: message,
|
||
timestamp: new Date()
|
||
});
|
||
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom();
|
||
this.autoResizeTextarea();
|
||
});
|
||
|
||
this.loading = true;
|
||
|
||
// 创建一个空的助手消息用于流式更新
|
||
const assistantMessage = {
|
||
role: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(),
|
||
metadata: null,
|
||
streaming: true // 标记为流式输出中
|
||
};
|
||
this.messages.push(assistantMessage);
|
||
const messageIndex = this.messages.length - 1;
|
||
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
|
||
// 使用流式API
|
||
const response = await fetch('/api/chat/message/stream', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify({
|
||
message: message,
|
||
session_id: this.sessionId
|
||
})
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
// Token过期或无效,跳转登录页
|
||
localStorage.removeItem('token');
|
||
window.location.href = '/static/login.html';
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error('请求失败');
|
||
}
|
||
|
||
// 读取流式响应
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let chunkCount = 0;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) {
|
||
|
||
break;
|
||
}
|
||
|
||
chunkCount++;
|
||
|
||
|
||
// 解码数据
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
|
||
// 保留最后一个不完整的行
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
|
||
|
||
if (data.type === 'session_id') {
|
||
this.sessionId = data.session_id;
|
||
} else if (data.type === 'content') {
|
||
// 追加内容 - 使用 Vue.set 确保响应式更新
|
||
const currentContent = this.messages[messageIndex].content;
|
||
this.messages[messageIndex].content = currentContent + data.content;
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom();
|
||
});
|
||
} else if (data.type === 'done') {
|
||
// 完成 - 标记流式输出结束
|
||
this.messages[messageIndex].streaming = false;
|
||
|
||
} else if (data.type === 'error') {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (e) {
|
||
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 确保流式标志被清除
|
||
this.messages[messageIndex].streaming = false;
|
||
|
||
} catch (error) {
|
||
|
||
this.messages[messageIndex].content = '抱歉,发送消息失败,请稍后重试。';
|
||
this.messages[messageIndex].streaming = false;
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
sendExample(exampleText) {
|
||
// Set the example text to input and send
|
||
this.userInput = exampleText;
|
||
this.sendMessage();
|
||
},
|
||
|
||
renderMarkdown(content) {
|
||
if (!content) return '';
|
||
|
||
// Configure marked options
|
||
marked.setOptions({
|
||
breaks: true,
|
||
gfm: true,
|
||
headerIds: false,
|
||
mangle: false
|
||
});
|
||
|
||
return marked.parse(content);
|
||
},
|
||
|
||
renderChart(index, data) {
|
||
const chartId = `chart-${index}`;
|
||
const container = document.getElementById(chartId);
|
||
|
||
if (!container || !data.kline_data) return;
|
||
|
||
const chart = LightweightCharts.createChart(container, {
|
||
width: container.clientWidth,
|
||
height: 400,
|
||
layout: {
|
||
background: { color: '#000000' },
|
||
textColor: '#a0a0a0'
|
||
},
|
||
grid: {
|
||
vertLines: { color: '#1a1a1a' },
|
||
horzLines: { color: '#1a1a1a' }
|
||
},
|
||
timeScale: {
|
||
borderColor: '#333333',
|
||
timeVisible: true
|
||
},
|
||
rightPriceScale: {
|
||
borderColor: '#333333'
|
||
}
|
||
});
|
||
|
||
const candlestickSeries = chart.addCandlestickSeries({
|
||
upColor: '#00ff41',
|
||
downColor: '#ff0040',
|
||
borderVisible: false,
|
||
wickUpColor: '#00ff41',
|
||
wickDownColor: '#ff0040'
|
||
});
|
||
|
||
const klineData = data.kline_data.map(item => ({
|
||
time: item.trade_date,
|
||
open: item.open,
|
||
high: item.high,
|
||
low: item.low,
|
||
close: item.close
|
||
}));
|
||
|
||
candlestickSeries.setData(klineData);
|
||
|
||
if (data.volume_data) {
|
||
const volumeSeries = chart.addHistogramSeries({
|
||
color: '#00ff4140',
|
||
priceFormat: {
|
||
type: 'volume'
|
||
},
|
||
priceScaleId: ''
|
||
});
|
||
|
||
const volumeData = data.volume_data.map(item => ({
|
||
time: item.trade_date,
|
||
value: item.vol,
|
||
color: item.close >= item.open ? '#00ff4140' : '#ff004040'
|
||
}));
|
||
|
||
volumeSeries.setData(volumeData);
|
||
}
|
||
|
||
chart.timeScale().fitContent();
|
||
|
||
this.charts[chartId] = chart;
|
||
|
||
// Handle resize
|
||
window.addEventListener('resize', () => {
|
||
if (this.charts[chartId]) {
|
||
chart.applyOptions({ width: container.clientWidth });
|
||
}
|
||
});
|
||
},
|
||
|
||
scrollToBottom() {
|
||
const container = this.$refs.chatContainer;
|
||
if (container) {
|
||
setTimeout(() => {
|
||
container.scrollTop = container.scrollHeight;
|
||
}, 100);
|
||
}
|
||
},
|
||
|
||
autoResizeTextarea() {
|
||
const textarea = this.$refs.textarea;
|
||
if (textarea) {
|
||
textarea.style.height = 'auto';
|
||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||
}
|
||
},
|
||
|
||
generateSessionId() {
|
||
return 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
},
|
||
|
||
copyWechat() {
|
||
const wechatId = 'aaronlzhou';
|
||
|
||
// 使用现代的 Clipboard API
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(wechatId).then(() => {
|
||
this.showCopyNotification();
|
||
}).catch(err => {
|
||
|
||
this.fallbackCopy(wechatId);
|
||
});
|
||
} else {
|
||
this.fallbackCopy(wechatId);
|
||
}
|
||
},
|
||
|
||
fallbackCopy(text) {
|
||
// 降级方案:使用传统方法
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = text;
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.opacity = '0';
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
|
||
try {
|
||
document.execCommand('copy');
|
||
this.showCopyNotification();
|
||
} catch (err) {
|
||
|
||
}
|
||
|
||
document.body.removeChild(textarea);
|
||
},
|
||
|
||
showCopyNotification() {
|
||
// 创建临时提示
|
||
const notification = document.createElement('div');
|
||
notification.textContent = '已复制微信号';
|
||
notification.style.cssText = `
|
||
position: fixed;
|
||
bottom: 80px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #00ff41;
|
||
color: #000000;
|
||
padding: 8px 16px;
|
||
border-radius: 2px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
z-index: 10000;
|
||
animation: fadeInOut 2s ease;
|
||
`;
|
||
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(() => {
|
||
document.body.removeChild(notification);
|
||
}, 2000);
|
||
},
|
||
|
||
copyMessage(content) {
|
||
// 移除HTML标签,只保留纯文本
|
||
const tempDiv = document.createElement('div');
|
||
tempDiv.innerHTML = marked.parse(content);
|
||
const plainText = tempDiv.textContent || tempDiv.innerText;
|
||
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(plainText).then(() => {
|
||
this.showNotification('已复制内容');
|
||
}).catch(err => {
|
||
|
||
this.fallbackCopy(plainText);
|
||
});
|
||
} else {
|
||
this.fallbackCopy(plainText);
|
||
}
|
||
},
|
||
|
||
async generateShareImage(content, index) {
|
||
try {
|
||
this.showNotification('正在生成分享图...');
|
||
|
||
// 获取用户提问(前一条消息)
|
||
let userQuestion = '';
|
||
if (index > 0 && this.messages[index - 1].role === 'user') {
|
||
userQuestion = this.messages[index - 1].content;
|
||
}
|
||
|
||
// 创建临时容器
|
||
const container = document.createElement('div');
|
||
container.className = 'share-image-container';
|
||
container.style.left = '-9999px';
|
||
|
||
// 构建分享图内容(包含用户提问和AI回答)
|
||
container.innerHTML = `
|
||
<div class="share-image-header">
|
||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" fill="#00ff41"/>
|
||
</svg>
|
||
<div class="share-image-logo">Tradus|AI 金融智能体</div>
|
||
</div>
|
||
${userQuestion ? `
|
||
<div class="share-image-question">
|
||
<div class="share-image-question-label">提问</div>
|
||
<div class="share-image-question-text">${userQuestion}</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="share-image-answer">
|
||
<div class="share-image-answer-label">AI 分析</div>
|
||
<div class="share-image-content">${marked.parse(content)}</div>
|
||
</div>
|
||
<div class="share-image-footer">
|
||
由 AI 智能分析生成 | 仅供参考,不构成投资建议
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(container);
|
||
|
||
// 等待渲染
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
// 生成图片
|
||
const canvas = await html2canvas(container, {
|
||
backgroundColor: '#0a0a0a',
|
||
scale: 2,
|
||
logging: false,
|
||
useCORS: true
|
||
});
|
||
|
||
// 移除临时容器
|
||
document.body.removeChild(container);
|
||
|
||
// 显示图片在模态框中
|
||
this.modalImageUrl = canvas.toDataURL('image/png');
|
||
this.showImageModal = true;
|
||
|
||
this.showNotification('长按图片可保存');
|
||
|
||
} catch (error) {
|
||
|
||
this.showNotification('生成失败,请重试');
|
||
}
|
||
},
|
||
|
||
closeImageModal() {
|
||
this.showImageModal = false;
|
||
this.modalImageUrl = '';
|
||
},
|
||
|
||
showNotification(text) {
|
||
const notification = document.createElement('div');
|
||
notification.textContent = text;
|
||
notification.style.cssText = `
|
||
position: fixed;
|
||
bottom: 80px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #00ff41;
|
||
color: #000000;
|
||
padding: 8px 16px;
|
||
border-radius: 2px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
z-index: 10000;
|
||
animation: fadeInOut 2s ease;
|
||
`;
|
||
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(() => {
|
||
if (document.body.contains(notification)) {
|
||
document.body.removeChild(notification);
|
||
}
|
||
}, 2000);
|
||
},
|
||
|
||
async loadModels() {
|
||
try {
|
||
const response = await fetch('/api/llm/models');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.availableModels = data.models;
|
||
this.currentModel = data.current;
|
||
this.selectedModel = data.current ? data.current.provider : null;
|
||
}
|
||
} catch (error) {
|
||
|
||
}
|
||
},
|
||
|
||
async switchModel() {
|
||
if (!this.selectedModel) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/llm/switch', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
provider: this.selectedModel
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.currentModel = data.current;
|
||
this.showNotification(`已切换到 ${data.current.name}`);
|
||
} else {
|
||
this.showNotification('切换失败');
|
||
}
|
||
} catch (error) {
|
||
|
||
this.showNotification('切换失败');
|
||
}
|
||
}
|
||
},
|
||
|
||
watch: {
|
||
userInput() {
|
||
this.$nextTick(() => {
|
||
this.autoResizeTextarea();
|
||
});
|
||
}
|
||
}
|
||
}).mount('#app');
|