diff --git a/src/core/UsageStatsService.js b/src/core/UsageStatsService.js index d9cf3a1..27d5094 100644 --- a/src/core/UsageStatsService.js +++ b/src/core/UsageStatsService.js @@ -16,6 +16,7 @@ class UsageStatsService { this.statsFilePath = path.join(this.dataDir, "usage-stats.jsonl"); this.enabled = enabled !== false; this.appendPromise = Promise.resolve(); + this.isImportingStats = false; if (this.enabled) { // Ensure data directory exists @@ -131,6 +132,12 @@ class UsageStatsService { if (!tracker) return null; this.activeRequests.delete(requestId); + if (this.isImportingStats) { + if (this.logger) { + this.logger.info(`[UsageStats] Dropped request ${requestId} because stats import is in progress.`); + } + return null; + } const finishedAtMs = Date.now(); const lastAttempt = tracker.attempts[tracker.attempts.length - 1] || null; @@ -240,28 +247,45 @@ class UsageStatsService { }; } - _loadFromFile() { - if (!this.enabled) return; - try { - if (!fs.existsSync(this.statsFilePath)) return; + /** + * Public import entry point. Drops newly finished stats during import, waits + * for already queued appends, then rewrites the stats file from a stable baseline. + */ + importJsonl(content) { + if (!this.enabled) { + throw new Error("Usage stats are disabled"); + } + if (this.isImportingStats) { + throw new Error("Usage stats import is already in progress"); + } + if (typeof content !== "string") { + throw new Error("Invalid JSONL content"); + } - const lines = fs.readFileSync(this.statsFilePath, "utf-8").split("\n").filter(Boolean); - this.records = []; - this.sequence = 0; + this.isImportingStats = true; - for (const line of lines) { - try { - const record = this._normalizeLoadedRecord(JSON.parse(line)); - this.records.push(record); - if (record.sequence > this.sequence) { - this.sequence = record.sequence; - } - } catch { - // Skip malformed lines - } - } + const importPromise = this.appendPromise + .catch(() => {}) + .then(() => this._importJsonlContent(content)) + .finally(() => { + this.isImportingStats = false; + }); + + this.appendPromise = importPromise.catch(() => {}); + return importPromise; + } + + /** + * Load persisted JSONL records during startup and rebuild derived in-memory + * aggregates from the records that can be parsed. + */ + _loadFromFile() { + try { + const { records } = this._readRecordsFromFile(); + if (records.length === 0 && !fs.existsSync(this.statsFilePath)) return; // Recalculate aggregates from all loaded records + this._replaceRecords(records); this._recalculateFromRecords(); if (this.logger) { @@ -287,9 +311,191 @@ class UsageStatsService { }); } - _recalculateFromRecords() { - this.startedAtMs = Date.now(); - this.startedAt = new Date(this.startedAtMs).toISOString(); + /** + * Import JSONL content, deduplicate by requestId, merge with current records, + * rewrite the file in finishedAt order, and rebuild memory state. + */ + async _importJsonlContent(content) { + if (!fs.existsSync(this.dataDir)) { + fs.mkdirSync(this.dataDir, { recursive: true }); + } + + const { records: existingRecords } = await this._readRecordsFromFileAsync(); + const uniqueExistingRecords = []; + const seenRequestIds = new Set(); + let duplicateCount = 0; + let missingRequestIdCount = 0; + + for (const record of existingRecords) { + const requestId = this._normalizeRequestId(record.requestId); + if (!requestId) { + missingRequestIdCount += 1; + continue; + } + + record.requestId = requestId; + if (seenRequestIds.has(requestId)) { + duplicateCount += 1; + continue; + } + + seenRequestIds.add(requestId); + uniqueExistingRecords.push(record); + } + + const importedRecords = []; + let invalidLineCount = 0; + const lines = content.split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let record; + try { + record = this._normalizeLoadedRecord(JSON.parse(trimmed)); + } catch { + invalidLineCount += 1; + continue; + } + + const requestId = this._normalizeRequestId(record.requestId); + if (!requestId) { + missingRequestIdCount += 1; + continue; + } + record.requestId = requestId; + + if (seenRequestIds.has(requestId)) { + duplicateCount += 1; + continue; + } + + seenRequestIds.add(requestId); + importedRecords.push(record); + } + + const mergedRecords = this._normalizeImportedRecords(uniqueExistingRecords.concat(importedRecords)); + const fileContent = mergedRecords.map(record => JSON.stringify(record)).join("\n"); + await fs.promises.writeFile(this.statsFilePath, fileContent ? `${fileContent}\n` : "", "utf-8"); + this._replaceRecords(mergedRecords); + this._recalculateFromRecords({ resetStartedAt: false }); + + if (this.logger) { + this.logger.info( + `[UsageStats] Imported ${importedRecords.length} records from JSONL. ` + + `Skipped ${duplicateCount} duplicates, ${invalidLineCount} invalid lines, ` + + `${missingRequestIdCount} without requestId.` + ); + } + + return { + duplicateCount, + importedCount: importedRecords.length, + invalidLineCount, + missingRequestIdCount, + totalRecords: mergedRecords.length, + }; + } + + /** + * Read valid records from the persisted usage-stats JSONL file. Malformed + * lines are ignored so one bad line does not prevent loading the rest. + */ + async _readRecordsFromFileAsync() { + try { + const content = await fs.promises.readFile(this.statsFilePath, "utf-8"); + return { records: this._parseRecordsContent(content) }; + } catch (error) { + if (error?.code === "ENOENT") { + return { records: [] }; + } + + throw error; + } + } + + _readRecordsFromFile() { + try { + const content = fs.readFileSync(this.statsFilePath, "utf-8"); + return { records: this._parseRecordsContent(content) }; + } catch (error) { + if (error?.code === "ENOENT") { + return { records: [] }; + } + + throw error; + } + } + + _parseRecordsContent(content) { + const records = []; + const lines = content.split(/\r?\n/).filter(Boolean); + for (const line of lines) { + try { + records.push(this._normalizeLoadedRecord(JSON.parse(line))); + } catch { + // Skip malformed lines + } + } + + return records; + } + + /** + * Normalize imported data by finished time and assign continuous sequence + * numbers from 1..N after records from multiple files have been merged. + */ + _normalizeImportedRecords(records) { + return records + .map((record, originalIndex) => ({ originalIndex, record })) + .sort((a, b) => { + const aTime = this._getRecordSortTime(a.record); + const bTime = this._getRecordSortTime(b.record); + if (aTime !== bTime) return aTime - bTime; + return a.originalIndex - b.originalIndex; + }) + .map((item, index) => ({ + ...item.record, + sequence: index + 1, + })); + } + + /** + * Return the timestamp used for import ordering: finishedAt first, then + * startedAt, then the previous sequence as a final fallback. + */ + _getRecordSortTime(record) { + const finishedAtMs = Date.parse(record?.finishedAt); + if (Number.isFinite(finishedAtMs)) return finishedAtMs; + + const startedAtMs = Date.parse(record?.startedAt); + if (Number.isFinite(startedAtMs)) return startedAtMs; + + const sequence = Number(record?.sequence); + return Number.isFinite(sequence) ? sequence : Number.MAX_SAFE_INTEGER; + } + + /** + * Replace the in-memory record list and reset sequence to the highest record + * sequence so new records continue after the current data set. + */ + _replaceRecords(records) { + this.records = records; + this.sequence = 0; + for (const record of records) { + if (record.sequence > this.sequence) { + this.sequence = record.sequence; + } + } + } + + _recalculateFromRecords(options = {}) { + const { resetStartedAt = true } = options; + if (resetStartedAt) { + this.startedAtMs = Date.now(); + this.startedAt = new Date(this.startedAtMs).toISOString(); + } this.summary = { abortedCount: 0, errorCount: 0, @@ -400,6 +606,15 @@ class UsageStatsService { }; } + /** + * Normalize requestId for import deduplication. Non-string IDs are treated + * as missing so they cannot be used as merge keys. + */ + _normalizeRequestId(value) { + if (typeof value !== "string") return ""; + return value.trim(); + } + _normalizeAuthIndex(value) { return Number.isInteger(value) && value >= 0 ? value : null; } diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 8540742..bfc2570 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -182,6 +182,71 @@ class StatusRoutes { res.json(snapshot || UsageStatsService.createEmptySnapshot()); }); + app.get("/api/usage-stats/download", isAuthenticated, async (req, res) => { + try { + const usageStatsService = this.serverSystem.usageStatsService; + if (!usageStatsService?.enabled) { + return res.status(403).json({ message: "usageStatsDisabled" }); + } + if (usageStatsService.isImportingStats) { + return res.status(409).json({ message: "usageStatsImportInProgress" }); + } + const statsFilePath = + usageStatsService?.statsFilePath || path.join(process.cwd(), "data", "usage-stats.jsonl"); + + if (usageStatsService?.appendPromise) { + await usageStatsService.appendPromise.catch(() => {}); + } + + if (!fs.existsSync(statsFilePath)) { + return res.status(404).json({ message: "usageStatsDownloadNoData" }); + } + + if (req.query.check === "1") { + return res.json({ ok: true }); + } + + res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8"); + res.sendFile(statsFilePath); + } catch (error) { + this.logger.error(`[WebUI] Failed to download usage stats: ${error.message}`); + res.status(500).json({ error: error.message, message: "usageStatsDownloadFailed" }); + } + }); + + app.post("/api/usage-stats/import", isAuthenticated, async (req, res) => { + try { + const usageStatsService = this.serverSystem.usageStatsService; + if (!usageStatsService?.enabled) { + return res.status(403).json({ message: "usageStatsDisabled" }); + } + if (usageStatsService.isImportingStats) { + return res.status(409).json({ message: "usageStatsImportInProgress" }); + } + + const { content, filename } = req.body || {}; + if (typeof filename !== "string" || !filename.toLowerCase().endsWith(".jsonl")) { + return res.status(400).json({ message: "usageStatsImportJsonlOnly" }); + } + if (typeof content !== "string") { + return res.status(400).json({ message: "usageStatsImportInvalidFile" }); + } + + const result = await usageStatsService.importJsonl(content); + res.json({ + duplicateCount: result.duplicateCount, + importedCount: result.importedCount, + invalidLineCount: result.invalidLineCount, + message: "usageStatsImportSuccess", + missingRequestIdCount: result.missingRequestIdCount, + totalRecords: result.totalRecords, + }); + } catch (error) { + this.logger.error(`[WebUI] Failed to import usage stats: ${error.message}`); + res.status(500).json({ error: error.message, message: "usageStatsImportFailed" }); + } + }); + app.put("/api/accounts/current", isAuthenticated, async (req, res) => { try { if (this._rejectIfSystemBusy(res)) return; diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 1f96d47..7699af5 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -1541,6 +1541,61 @@

{{ t("usageStats") }}

+ + + isDownloadingUsageStats.value || isImportingUsageStats.value); // Create reactive version counter const langVersion = ref(0); @@ -4684,11 +4743,7 @@ const downloadAccountByIndex = accountIndex => { window.location.href = `/api/files/auth-${accountIndex}.json`; }; -// Download current logs -const downloadCurrentLogs = () => { - if (!state.logs) return; - - const blob = new Blob([state.logs], { type: "text/plain" }); +const formatDownloadTimestamp = () => { const now = new Date(); const YYYY = now.getFullYear(); const MM = String(now.getMonth() + 1).padStart(2, "0"); @@ -4697,7 +4752,150 @@ const downloadCurrentLogs = () => { const mm = String(now.getMinutes()).padStart(2, "0"); const ss = String(now.getSeconds()).padStart(2, "0"); - const filename = `AIStudioProxy_${YYYY}-${MM}-${DD}_${HH}${mm}${ss}_${state.logCount}.log`; + return `${YYYY}-${MM}-${DD}_${HH}${mm}${ss}`; +}; + +const readFileAsText = file => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve(e.target.result); + reader.onerror = () => reject(new Error(t("fileReadFailed"))); + reader.readAsText(file); + }); + +const triggerUsageStatsImport = () => { + if (isUsageStatsTransferBusy.value) return; + + ElMessageBox.confirm(t("usageStatsImportConfirm"), t("warningTitle"), { + cancelButtonText: t("cancel"), + confirmButtonText: t("ok"), + lockScroll: false, + type: "warning", + }) + .then(() => usageStatsImportInput.value?.click()) + .catch(e => { + if (e !== "cancel") { + console.error(e); + } + }); +}; + +const handleUsageStatsImport = async event => { + const [file] = Array.from(event.target.files || []); + event.target.value = ""; + if (!file) return; + if (isUsageStatsTransferBusy.value) return; + + if (!file.name.toLowerCase().endsWith(".jsonl")) { + ElMessage.error(t("usageStatsImportJsonlOnly")); + return; + } + + isImportingUsageStats.value = true; + try { + const content = await readFileAsText(file); + const res = await fetch("/api/usage-stats/import", { + body: JSON.stringify({ content, filename: file.name }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + + if (res.redirected) { + window.location.href = res.url; + return; + } + if (res.status === 401) { + window.location.href = "/login"; + return; + } + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + showUsageStatsImportNotification({ + message: t(data.message || "usageStatsImportFailed", { error: data.error || `HTTP ${res.status}` }), + title: t("usageStatsImportResult"), + type: "error", + }); + return; + } + + showUsageStatsImportNotification({ + message: t("usageStatsImportSuccess", data), + title: t("usageStatsImportResult"), + type: "success", + }); + await fetchUsageStats(); + } catch (error) { + showUsageStatsImportNotification({ + message: t("usageStatsImportFailed", { error: error.message }), + title: t("usageStatsImportResult"), + type: "error", + }); + } finally { + isImportingUsageStats.value = false; + } +}; + +const showUsageStatsImportNotification = ({ message, title, type }) => { + ElNotification({ + dangerouslyUseHTMLString: true, + duration: 0, + message: `
${escapeHtml(message)}
`, + position: "top-right", + title, + type, + }); +}; + +// Download persisted usage stats JSONL +const downloadUsageStats = async () => { + if (isUsageStatsTransferBusy.value) return; + isDownloadingUsageStats.value = true; + + try { + const res = await fetch("/api/usage-stats/download?check=1"); + if (res.redirected) { + window.location.href = res.url; + return; + } + if (res.status === 401) { + window.location.href = "/login"; + return; + } + if (!res.ok) { + let message = "usageStatsDownloadFailed"; + try { + const data = await res.json(); + message = data.message || message; + ElMessage.error(t(message, { error: data.error || `HTTP ${res.status}` })); + return; + } catch { + // Ignore non-JSON error responses. + } + ElMessage.error(t(message, { error: `HTTP ${res.status}` })); + return; + } + + const filename = `AIStudioToAPI_usage-stats_${formatDownloadTimestamp()}.jsonl`; + const a = document.createElement("a"); + a.href = "/api/usage-stats/download"; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (error) { + ElMessage.error(t("usageStatsDownloadFailed", { error: error.message })); + } finally { + isDownloadingUsageStats.value = false; + } +}; + +// Download current logs +const downloadCurrentLogs = () => { + if (!state.logs) return; + + const blob = new Blob([state.logs], { type: "text/plain" }); + const filename = `AIStudioToAPI_${formatDownloadTimestamp()}_${state.logCount}.log`; const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -5685,6 +5883,20 @@ watchEffect(() => { } } +.stats-download-button { + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + cursor: pointer; + font-family: inherit; + + &:disabled { + cursor: wait; + opacity: 0.65; + } +} + .time-range-select { width: 130px; diff --git a/ui/locales/en.json b/ui/locales/en.json index ae5e86c..42302da 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -124,6 +124,7 @@ "errorInvalidMode": "Invalid mode. Use 'fake' or 'real'.", "errorUnknown": "Unknown error", "expand": "Expand", + "exportUsageStats": "Export usage stats", "failed": "Failed", "fake": "Fake", "fakeStream": "Fake Streaming", @@ -144,6 +145,7 @@ "gemini": "Gemini", "generation": "Generation", "immediateSwitchCodes": "Immediate Switch (Codes)", + "importUsageStats": "Import usage stats", "invalidJson": "Invalid JSON", "jsonFormatError": "N/A (JSON format error)", "language": "Language", @@ -266,6 +268,16 @@ "usageCount": "Usage Count", "usageFilters": "Usage Filters", "usageStats": "Usage Stats", + "usageStatsDisabled": "Usage stats are disabled.", + "usageStatsDownloadFailed": "Failed to export usage stats: {error}", + "usageStatsDownloadNoData": "No usage stats file is available to export.", + "usageStatsImportConfirm": "Import will merge and then rewrite the local stats file, and reorder records by finish time. Please do not send API requests during import; any stats written during import may be overwritten by the import result. Continue?", + "usageStatsImportFailed": "Failed to import usage stats: {error}", + "usageStatsImportInProgress": "Usage stats import is already in progress.", + "usageStatsImportInvalidFile": "Invalid usage stats file.", + "usageStatsImportJsonlOnly": "Only .jsonl files can be imported.", + "usageStatsImportResult": "Usage Stats Import Result", + "usageStatsImportSuccess": "Import complete: added {importedCount}, skipped {duplicateCount} duplicates, {invalidLineCount} invalid lines, {missingRequestIdCount} without request ID.", "usernamePlaceholder": "Username", "versionInfo": "Version Info", "viewRelease": "View Release", diff --git a/ui/locales/zh.json b/ui/locales/zh.json index 9ff8f8b..9834cc6 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -124,6 +124,7 @@ "errorInvalidMode": "无效的模式。请使用 'fake' 或 'real'。", "errorUnknown": "未知错误", "expand": "展开", + "exportUsageStats": "导出统计数据", "failed": "失败", "fake": "假", "fakeStream": "假流式", @@ -144,6 +145,7 @@ "gemini": "Gemini", "generation": "生成请求", "immediateSwitchCodes": "立即切换(状态码)", + "importUsageStats": "导入统计数据", "invalidJson": "无效的 JSON 格式", "jsonFormatError": "N/A (JSON 格式错误)", "language": "语言", @@ -266,6 +268,16 @@ "usageCount": "使用次数", "usageFilters": "\u7edf\u8ba1\u7b5b\u9009", "usageStats": "使用统计", + "usageStatsDisabled": "使用统计已关闭。", + "usageStatsDownloadFailed": "导出统计数据失败:{error}", + "usageStatsDownloadNoData": "暂无可导出的统计数据文件。", + "usageStatsImportConfirm": "导入会合并后重写本地统计文件,并按完成时间重新排序。导入期间请不要发送 API 请求;如果期间产生新的统计写入,可能会被导入结果覆盖。是否继续?", + "usageStatsImportFailed": "导入统计数据失败:{error}", + "usageStatsImportInProgress": "统计数据正在导入中。", + "usageStatsImportInvalidFile": "无效的统计数据文件。", + "usageStatsImportJsonlOnly": "仅支持导入 .jsonl 文件。", + "usageStatsImportResult": "统计数据导入结果", + "usageStatsImportSuccess": "导入完成:新增 {importedCount} 条,跳过重复 {duplicateCount} 条,格式错误 {invalidLineCount} 条,缺少请求 ID {missingRequestIdCount} 条。", "usernamePlaceholder": "用户名", "versionInfo": "版本信息", "viewRelease": "查看版本发布",