stock-ai-agent/frontend/js/app.js
2026-02-10 00:04:22 +08:00

486 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Vue 3 Application
const { createApp } = Vue;
createApp({
data() {
return {
messages: [],
userInput: '',
loading: false,
sessionId: null,
charts: {},
showImageModal: false,
modalImageUrl: '',
showContactModal: false,
currentModel: 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">TradusAI 金融智能体</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.currentModel = data.current;
}
} catch (error) {
// 忽略错误
}
}
},
watch: {
userInput() {
this.$nextTick(() => {
this.autoResizeTextarea();
});
}
}
}).mount('#app');