- Removed automatic TA reload when switching timeframes - TA panel now only loads on initial page load and when manually refreshed - User must click Refresh button to update TA after timeframe change
621 lines
24 KiB
JavaScript
621 lines
24 KiB
JavaScript
import { INTERVALS, COLORS } from '../core/index.js';
|
|
|
|
export class TradingDashboard {
|
|
constructor() {
|
|
this.chart = null;
|
|
this.candleSeries = null;
|
|
this.currentInterval = '1d';
|
|
this.intervals = INTERVALS;
|
|
this.allData = new Map();
|
|
this.isLoading = false;
|
|
this.hasInitialLoad = false;
|
|
this.taData = null;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.createTimeframeButtons();
|
|
this.initChart();
|
|
this.initEventListeners();
|
|
this.loadInitialData();
|
|
|
|
setInterval(() => {
|
|
this.loadNewData();
|
|
this.loadStats();
|
|
if (new Date().getSeconds() < 15) this.loadTA();
|
|
}, 10000);
|
|
}
|
|
|
|
isAtRightEdge() {
|
|
const timeScale = this.chart.timeScale();
|
|
const visibleRange = timeScale.getVisibleLogicalRange();
|
|
if (!visibleRange) return true;
|
|
|
|
const data = this.candleSeries.data();
|
|
if (!data || data.length === 0) return true;
|
|
|
|
return visibleRange.to >= data.length - 5;
|
|
}
|
|
|
|
createTimeframeButtons() {
|
|
const container = document.getElementById('timeframeContainer');
|
|
container.innerHTML = '';
|
|
this.intervals.forEach(interval => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'timeframe-btn';
|
|
btn.dataset.interval = interval;
|
|
btn.textContent = interval;
|
|
if (interval === this.currentInterval) {
|
|
btn.classList.add('active');
|
|
}
|
|
btn.addEventListener('click', () => this.switchTimeframe(interval));
|
|
container.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
initChart() {
|
|
const chartContainer = document.getElementById('chart');
|
|
|
|
this.chart = LightweightCharts.createChart(chartContainer, {
|
|
layout: {
|
|
background: { color: COLORS.tvBg },
|
|
textColor: COLORS.tvText,
|
|
panes: {
|
|
background: { color: '#1e222d' },
|
|
separatorColor: '#2a2e39',
|
|
separatorHoverColor: '#363c4e',
|
|
enableResize: true
|
|
}
|
|
},
|
|
grid: {
|
|
vertLines: { color: '#363d4e' },
|
|
horzLines: { color: '#363d4e' },
|
|
},
|
|
rightPriceScale: {
|
|
borderColor: '#363d4e',
|
|
autoScale: true,
|
|
},
|
|
timeScale: {
|
|
borderColor: '#363d4e',
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
rightOffset: 12,
|
|
barSpacing: 10,
|
|
},
|
|
handleScroll: {
|
|
vertTouchDrag: false,
|
|
},
|
|
});
|
|
|
|
this.candleSeries = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
|
upColor: '#ff9800',
|
|
downColor: '#ff9800',
|
|
borderUpColor: '#ff9800',
|
|
borderDownColor: '#ff9800',
|
|
wickUpColor: '#ff9800',
|
|
wickDownColor: '#ff9800',
|
|
lastValueVisible: false,
|
|
priceLineVisible: false,
|
|
}, 0);
|
|
|
|
this.currentPriceLine = this.candleSeries.createPriceLine({
|
|
price: 0,
|
|
color: '#26a69a',
|
|
lineWidth: 1,
|
|
lineStyle: LightweightCharts.LineStyle.Dotted,
|
|
axisLabelVisible: true,
|
|
title: '',
|
|
});
|
|
|
|
this.initPriceScaleControls();
|
|
this.initNavigationControls();
|
|
|
|
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.onVisibleRangeChange.bind(this));
|
|
|
|
window.addEventListener('resize', () => {
|
|
this.chart.applyOptions({
|
|
width: chartContainer.clientWidth,
|
|
height: chartContainer.clientHeight,
|
|
});
|
|
});
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') {
|
|
this.loadNewData();
|
|
this.loadTA();
|
|
}
|
|
});
|
|
window.addEventListener('focus', () => {
|
|
this.loadNewData();
|
|
this.loadTA();
|
|
});
|
|
}
|
|
|
|
initPriceScaleControls() {
|
|
const btnAutoScale = document.getElementById('btnAutoScale');
|
|
const btnLogScale = document.getElementById('btnLogScale');
|
|
|
|
if (!btnAutoScale || !btnLogScale) return;
|
|
|
|
this.priceScaleState = {
|
|
autoScale: true,
|
|
logScale: false
|
|
};
|
|
|
|
btnAutoScale.addEventListener('click', () => {
|
|
this.priceScaleState.autoScale = !this.priceScaleState.autoScale;
|
|
btnAutoScale.classList.toggle('active', this.priceScaleState.autoScale);
|
|
|
|
this.candleSeries.priceScale().applyOptions({
|
|
autoScale: this.priceScaleState.autoScale
|
|
});
|
|
|
|
console.log('Auto Scale:', this.priceScaleState.autoScale ? 'ON' : 'OFF');
|
|
});
|
|
|
|
btnLogScale.addEventListener('click', () => {
|
|
this.priceScaleState.logScale = !this.priceScaleState.logScale;
|
|
btnLogScale.classList.toggle('active', this.priceScaleState.logScale);
|
|
|
|
let currentPriceRange = null;
|
|
let currentTimeRange = null;
|
|
if (!this.priceScaleState.autoScale) {
|
|
try {
|
|
currentPriceRange = this.candleSeries.priceScale().getVisiblePriceRange();
|
|
} catch (e) {
|
|
console.log('Could not get price range');
|
|
}
|
|
}
|
|
try {
|
|
currentTimeRange = this.chart.timeScale().getVisibleLogicalRange();
|
|
} catch (e) {
|
|
console.log('Could not get time range');
|
|
}
|
|
|
|
this.candleSeries.priceScale().applyOptions({
|
|
mode: this.priceScaleState.logScale ? LightweightCharts.PriceScaleMode.Logarithmic : LightweightCharts.PriceScaleMode.Normal
|
|
});
|
|
|
|
this.chart.applyOptions({});
|
|
|
|
setTimeout(() => {
|
|
if (currentTimeRange) {
|
|
try {
|
|
this.chart.timeScale().setVisibleLogicalRange(currentTimeRange);
|
|
} catch (e) {
|
|
console.log('Could not restore time range');
|
|
}
|
|
}
|
|
|
|
if (!this.priceScaleState.autoScale && currentPriceRange) {
|
|
try {
|
|
this.candleSeries.priceScale().setVisiblePriceRange(currentPriceRange);
|
|
} catch (e) {
|
|
console.log('Could not restore price range');
|
|
}
|
|
}
|
|
}, 100);
|
|
|
|
console.log('Log Scale:', this.priceScaleState.logScale ? 'ON' : 'OFF');
|
|
});
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'a' || e.key === 'A') {
|
|
if (e.target.tagName !== 'INPUT') {
|
|
btnAutoScale.click();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
initNavigationControls() {
|
|
const chartWrapper = document.getElementById('chartWrapper');
|
|
const navLeft = document.getElementById('navLeft');
|
|
const navRight = document.getElementById('navRight');
|
|
const navRecent = document.getElementById('navRecent');
|
|
|
|
if (!chartWrapper || !navLeft || !navRight || !navRecent) return;
|
|
|
|
chartWrapper.addEventListener('mousemove', (e) => {
|
|
const rect = chartWrapper.getBoundingClientRect();
|
|
const distanceFromBottom = rect.bottom - e.clientY;
|
|
chartWrapper.classList.toggle('show-nav', distanceFromBottom < 30);
|
|
});
|
|
|
|
chartWrapper.addEventListener('mouseleave', () => {
|
|
chartWrapper.classList.remove('show-nav');
|
|
});
|
|
|
|
navLeft.addEventListener('click', () => this.navigateLeft());
|
|
navRight.addEventListener('click', () => this.navigateRight());
|
|
navRecent.addEventListener('click', () => this.navigateToRecent());
|
|
}
|
|
|
|
navigateLeft() {
|
|
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
|
|
if (!visibleRange) return;
|
|
|
|
const visibleBars = visibleRange.to - visibleRange.from;
|
|
const shift = visibleBars * 0.8;
|
|
const newFrom = visibleRange.from - shift;
|
|
const newTo = visibleRange.to - shift;
|
|
|
|
this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo });
|
|
}
|
|
|
|
navigateRight() {
|
|
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
|
|
if (!visibleRange) return;
|
|
|
|
const visibleBars = visibleRange.to - visibleRange.from;
|
|
const shift = visibleBars * 0.8;
|
|
const newFrom = visibleRange.from + shift;
|
|
const newTo = visibleRange.to + shift;
|
|
|
|
this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo });
|
|
}
|
|
|
|
navigateToRecent() {
|
|
this.chart.timeScale().scrollToRealTime();
|
|
}
|
|
|
|
initEventListeners() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return;
|
|
|
|
const shortcuts = {
|
|
'1': '1m', '2': '3m', '3': '5m', '4': '15m', '5': '30m', '7': '37m',
|
|
'6': '1h', '8': '4h', '9': '8h', '0': '12h',
|
|
'd': '1d', 'D': '1d', 'w': '1w', 'W': '1w', 'm': '1M', 'M': '1M'
|
|
};
|
|
|
|
if (shortcuts[e.key]) {
|
|
this.switchTimeframe(shortcuts[e.key]);
|
|
}
|
|
|
|
if (e.key === 'ArrowLeft') {
|
|
this.navigateLeft();
|
|
} else if (e.key === 'ArrowRight') {
|
|
this.navigateRight();
|
|
} else if (e.key === 'ArrowUp') {
|
|
this.navigateToRecent();
|
|
}
|
|
});
|
|
}
|
|
|
|
async loadInitialData() {
|
|
await Promise.all([
|
|
this.loadData(1000, true),
|
|
this.loadStats()
|
|
]);
|
|
this.hasInitialLoad = true;
|
|
this.loadTA();
|
|
}
|
|
|
|
async loadData(limit = 1000, fitToContent = false) {
|
|
if (this.isLoading) return;
|
|
this.isLoading = true;
|
|
|
|
try {
|
|
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
|
|
|
|
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=${limit}`);
|
|
const data = await response.json();
|
|
|
|
if (data.candles && data.candles.length > 0) {
|
|
const chartData = data.candles.reverse().map(c => ({
|
|
time: Math.floor(new Date(c.time).getTime() / 1000),
|
|
open: parseFloat(c.open),
|
|
high: parseFloat(c.high),
|
|
low: parseFloat(c.low),
|
|
close: parseFloat(c.close),
|
|
volume: parseFloat(c.volume || 0)
|
|
}));
|
|
|
|
const existingData = this.allData.get(this.currentInterval) || [];
|
|
const mergedData = this.mergeData(existingData, chartData);
|
|
this.allData.set(this.currentInterval, mergedData);
|
|
|
|
this.candleSeries.setData(mergedData);
|
|
|
|
if (fitToContent) {
|
|
this.chart.timeScale().scrollToRealTime();
|
|
} else if (visibleRange) {
|
|
this.chart.timeScale().setVisibleLogicalRange(visibleRange);
|
|
}
|
|
|
|
const latest = mergedData[mergedData.length - 1];
|
|
this.updateStats(latest);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading data:', error);
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async loadNewData() {
|
|
if (!this.hasInitialLoad || this.isLoading) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=50`);
|
|
const data = await response.json();
|
|
|
|
if (data.candles && data.candles.length > 0) {
|
|
const atEdge = this.isAtRightEdge();
|
|
|
|
const currentSeriesData = this.candleSeries.data();
|
|
const lastTimestamp = currentSeriesData.length > 0
|
|
? currentSeriesData[currentSeriesData.length - 1].time
|
|
: 0;
|
|
|
|
const chartData = data.candles.reverse().map(c => ({
|
|
time: Math.floor(new Date(c.time).getTime() / 1000),
|
|
open: parseFloat(c.open),
|
|
high: parseFloat(c.high),
|
|
low: parseFloat(c.low),
|
|
close: parseFloat(c.close),
|
|
volume: parseFloat(c.volume || 0)
|
|
}));
|
|
|
|
chartData.forEach(candle => {
|
|
if (candle.time >= lastTimestamp) {
|
|
this.candleSeries.update(candle);
|
|
}
|
|
});
|
|
|
|
const existingData = this.allData.get(this.currentInterval) || [];
|
|
this.allData.set(this.currentInterval, this.mergeData(existingData, chartData));
|
|
|
|
if (atEdge) {
|
|
this.chart.timeScale().scrollToRealTime();
|
|
}
|
|
|
|
const latest = chartData[chartData.length - 1];
|
|
this.updateStats(latest);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading new data:', error);
|
|
}
|
|
}
|
|
|
|
mergeData(existing, newData) {
|
|
const dataMap = new Map();
|
|
existing.forEach(c => dataMap.set(c.time, c));
|
|
newData.forEach(c => dataMap.set(c.time, c));
|
|
return Array.from(dataMap.values()).sort((a, b) => a.time - b.time);
|
|
}
|
|
|
|
onVisibleRangeChange() {
|
|
if (!this.hasInitialLoad || this.isLoading) {
|
|
return;
|
|
}
|
|
|
|
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
|
|
if (!visibleRange) {
|
|
return;
|
|
}
|
|
|
|
const data = this.candleSeries.data();
|
|
if (!data || data.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const visibleBars = Math.ceil(visibleRange.to - visibleRange.from);
|
|
const bufferSize = visibleBars * 2;
|
|
const refillThreshold = bufferSize * 0.8;
|
|
const barsFromLeft = Math.floor(visibleRange.from);
|
|
|
|
if (barsFromLeft < refillThreshold) {
|
|
console.log(`Buffer low (${barsFromLeft} < ${refillThreshold.toFixed(0)}), silently prefetching ${bufferSize} candles...`);
|
|
const oldestCandle = data[0];
|
|
if (oldestCandle) {
|
|
this.loadHistoricalData(oldestCandle.time, bufferSize);
|
|
}
|
|
}
|
|
}
|
|
|
|
async loadHistoricalData(beforeTime, limit = 1000) {
|
|
if (this.isLoading) {
|
|
return;
|
|
}
|
|
this.isLoading = true;
|
|
|
|
try {
|
|
const endTime = new Date((beforeTime - 1) * 1000);
|
|
|
|
const response = await fetch(
|
|
`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&end=${endTime.toISOString()}&limit=${limit}`
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.candles && data.candles.length > 0) {
|
|
const chartData = data.candles.reverse().map(c => ({
|
|
time: Math.floor(new Date(c.time).getTime() / 1000),
|
|
open: parseFloat(c.open),
|
|
high: parseFloat(c.high),
|
|
low: parseFloat(c.low),
|
|
close: parseFloat(c.close),
|
|
volume: parseFloat(c.volume || 0)
|
|
}));
|
|
|
|
const existingData = this.allData.get(this.currentInterval) || [];
|
|
const mergedData = this.mergeData(existingData, chartData);
|
|
this.allData.set(this.currentInterval, mergedData);
|
|
|
|
this.candleSeries.setData(mergedData);
|
|
|
|
// Recalculate indicators with the expanded dataset
|
|
window.drawIndicatorsOnChart?.();
|
|
|
|
console.log(`Prefetched ${chartData.length} candles, total: ${mergedData.length}`);
|
|
} else {
|
|
console.log('No more historical data available');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading historical data:', error);
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async loadTA() {
|
|
if (!this.hasInitialLoad) {
|
|
const time = new Date().toLocaleTimeString();
|
|
document.getElementById('taContent').innerHTML = `<div class="ta-loading">Loading technical analysis... ${time}</div>`;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/ta?symbol=BTC&interval=${this.currentInterval}`);
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
document.getElementById('taContent').innerHTML = `<div class="ta-error">${data.error}</div>`;
|
|
return;
|
|
}
|
|
|
|
this.taData = data;
|
|
this.renderTA();
|
|
} catch (error) {
|
|
console.error('Error loading TA:', error);
|
|
document.getElementById('taContent').innerHTML = '<div class="ta-error">Failed to load technical analysis. Please check if the database has candle data.</div>';
|
|
}
|
|
}
|
|
|
|
renderTA() {
|
|
if (!this.taData || this.taData.error) {
|
|
document.getElementById('taContent').innerHTML = `<div class="ta-error">${this.taData?.error || 'No data available'}</div>`;
|
|
return;
|
|
}
|
|
|
|
const data = this.taData;
|
|
const trendClass = data.trend.direction.toLowerCase();
|
|
const signalClass = data.trend.signal.toLowerCase();
|
|
|
|
const ma44Change = data.moving_averages.price_vs_ma44;
|
|
const ma125Change = data.moving_averages.price_vs_ma125;
|
|
|
|
document.getElementById('taInterval').textContent = this.currentInterval.toUpperCase();
|
|
document.getElementById('taLastUpdate').textContent = new Date().toLocaleTimeString();
|
|
|
|
document.getElementById('taContent').innerHTML = `
|
|
<div class="ta-section">
|
|
<div class="ta-section-title">Trend Analysis</div>
|
|
<div class="ta-trend ${trendClass}">
|
|
${data.trend.direction} ${trendClass === 'bullish' ? '↑' : trendClass === 'bearish' ? '↓' : '→'}
|
|
</div>
|
|
<div class="ta-strength">${data.trend.strength}</div>
|
|
<span class="ta-signal ${signalClass}">${data.trend.signal}</span>
|
|
</div>
|
|
|
|
<div class="ta-section">
|
|
<div class="ta-section-title">Moving Averages</div>
|
|
<div class="ta-ma-row">
|
|
<span class="ta-ma-label">MA 44</span>
|
|
<span class="ta-ma-value">
|
|
${data.moving_averages.ma_44 ? data.moving_averages.ma_44.toFixed(2) : 'N/A'}
|
|
${ma44Change !== null ? `<span class="ta-ma-change ${ma44Change >= 0 ? 'positive' : 'negative'}">${ma44Change >= 0 ? '+' : ''}${ma44Change.toFixed(1)}%</span>` : ''}
|
|
</span>
|
|
</div>
|
|
<div class="ta-ma-row">
|
|
<span class="ta-ma-label">MA 125</span>
|
|
<span class="ta-ma-value">
|
|
${data.moving_averages.ma_125 ? data.moving_averages.ma_125.toFixed(2) : 'N/A'}
|
|
${ma125Change !== null ? `<span class="ta-ma-change ${ma125Change >= 0 ? 'positive' : 'negative'}">${ma125Change >= 0 ? '+' : ''}${ma125Change.toFixed(1)}%</span>` : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="ta-section">
|
|
<div class="ta-section-title">Indicators</div>
|
|
<div id="indicatorList" class="indicator-list"></div>
|
|
</div>
|
|
|
|
<div class="ta-section" id="indicatorConfigPanel">
|
|
<div class="ta-section-title">Configuration</div>
|
|
<div id="configForm" style="margin-top: 8px;"></div>
|
|
<div style="display: flex; gap: 8px; margin-top: 12px;" id="configButtons">
|
|
<button class="ta-btn" onclick="applyIndicatorConfig()" style="flex: 1; font-size: 11px; background: var(--tv-blue); color: white; border: none;">Apply</button>
|
|
<button class="ta-btn" onclick="removeIndicator()" style="flex: 1; font-size: 11px; border-color: var(--tv-red); color: var(--tv-red);">Remove</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
window.renderIndicatorList?.();
|
|
}
|
|
|
|
async loadStats() {
|
|
try {
|
|
const response = await fetch('/api/v1/stats?symbol=BTC');
|
|
this.statsData = await response.json();
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error);
|
|
}
|
|
}
|
|
|
|
updateStats(candle) {
|
|
const price = candle.close;
|
|
const isUp = candle.close >= candle.open;
|
|
|
|
if (this.currentPriceLine) {
|
|
this.currentPriceLine.applyOptions({
|
|
price: price,
|
|
color: isUp ? '#26a69a' : '#ef5350',
|
|
});
|
|
}
|
|
|
|
document.getElementById('currentPrice').textContent = price.toFixed(2);
|
|
|
|
if (this.statsData) {
|
|
const change = this.statsData.change_24h;
|
|
document.getElementById('currentPrice').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative');
|
|
document.getElementById('priceChange').textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%';
|
|
document.getElementById('priceChange').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative');
|
|
document.getElementById('dailyHigh').textContent = this.statsData.high_24h.toFixed(2);
|
|
document.getElementById('dailyLow').textContent = this.statsData.low_24h.toFixed(2);
|
|
}
|
|
}
|
|
|
|
switchTimeframe(interval) {
|
|
if (!this.intervals.includes(interval) || interval === this.currentInterval) return;
|
|
|
|
this.currentInterval = interval;
|
|
this.hasInitialLoad = false;
|
|
|
|
document.querySelectorAll('.timeframe-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.interval === interval);
|
|
});
|
|
|
|
this.allData.delete(interval);
|
|
this.loadInitialData();
|
|
// Don't reload TA on timeframe switch - let user refresh manually
|
|
|
|
window.clearSimulationResults?.();
|
|
window.updateTimeframeDisplay?.();
|
|
}
|
|
}
|
|
|
|
export function refreshTA() {
|
|
if (window.dashboard) {
|
|
const time = new Date().toLocaleTimeString();
|
|
document.getElementById('taContent').innerHTML = `<div class="ta-loading">Refreshing... ${time}</div>`;
|
|
window.dashboard.loadTA();
|
|
}
|
|
}
|
|
|
|
export function openAIAnalysis() {
|
|
const symbol = 'BTC';
|
|
const interval = window.dashboard?.currentInterval || '1d';
|
|
const prompt = `Analyze Bitcoin (${symbol}) ${interval} chart. Current trend, support/resistance levels, and trading recommendation. Technical indicators: MA44, MA125.`;
|
|
|
|
const geminiUrl = `https://gemini.google.com/app?prompt=${encodeURIComponent(prompt)}`;
|
|
window.open(geminiUrl, '_blank');
|
|
}
|