mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
Merge origin/main into fix/gemini-cli-v1internal
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatCompactNumber, formatTokens, formatUsageCount } from '../format'
|
||||
|
||||
describe('format utils', () => {
|
||||
it('formats compact numbers beyond millions', () => {
|
||||
expect(formatCompactNumber(999)).toBe('999')
|
||||
expect(formatCompactNumber(1_250)).toBe('1.25K')
|
||||
expect(formatCompactNumber(12_500_000)).toBe('12.5M')
|
||||
expect(formatCompactNumber(1_250_000_000)).toBe('1.25B')
|
||||
expect(formatCompactNumber(12_500_000_000_000)).toBe('12.5T')
|
||||
})
|
||||
|
||||
it('uses the same B/T compact units for tokens and usage counts', () => {
|
||||
expect(formatTokens(1_000_000_000)).toBe('1B')
|
||||
expect(formatTokens(1_500_000_000_000)).toBe('1.5T')
|
||||
expect(formatUsageCount(1_000_000_000)).toBe('1B')
|
||||
})
|
||||
})
|
||||
@@ -142,6 +142,28 @@ describe('providerKeyQuota', () => {
|
||||
}, 'gemini_cli')).toBe('AI Credits 剩余 41.5')
|
||||
})
|
||||
|
||||
it('formats ChatGPT Web image quota as remaining count', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
provider_type: 'chatgpt_web',
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
windows: [
|
||||
{
|
||||
code: 'image_gen',
|
||||
scope: 'account',
|
||||
remaining_ratio: 0.96,
|
||||
used_value: 1,
|
||||
remaining_value: 24,
|
||||
limit_value: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}, 'chatgpt_web')).toBe('生图剩余 24/25')
|
||||
})
|
||||
|
||||
it('surfaces Windsurf hard account states', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeReleaseNotesForDisplay, trimReleaseNotesForDisplay } from '../releaseNotes'
|
||||
|
||||
describe('trimReleaseNotesForDisplay', () => {
|
||||
it('keeps manual notes and removes GitHub auto-generated sections', () => {
|
||||
const input = [
|
||||
'### Features',
|
||||
'- 新增在线更新',
|
||||
'',
|
||||
'### Bug Fixes',
|
||||
'- 修复版本检查',
|
||||
'',
|
||||
'## What\'s Changed',
|
||||
'* fix: something by @someone in https://github.com/example/repo/pull/1',
|
||||
'',
|
||||
'## New Contributors',
|
||||
'* @someone made their first contribution',
|
||||
'',
|
||||
'**Full Changelog**: https://github.com/example/repo/compare/v1...v2',
|
||||
].join('\n')
|
||||
|
||||
expect(trimReleaseNotesForDisplay(input)).toBe([
|
||||
'### Features',
|
||||
'- 新增在线更新',
|
||||
'',
|
||||
'### Bug Fixes',
|
||||
'- 修复版本检查',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('removes standalone full changelog lines', () => {
|
||||
const input = '**Full Changelog**: https://github.com/example/repo/compare/v1...v2'
|
||||
expect(trimReleaseNotesForDisplay(input)).toBe('')
|
||||
})
|
||||
|
||||
it('returns original notes when there is no auto-generated footer', () => {
|
||||
const input = [
|
||||
'### Features',
|
||||
'- 支持历史版本切换',
|
||||
].join('\n')
|
||||
|
||||
expect(trimReleaseNotesForDisplay(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeReleaseNotesForDisplay', () => {
|
||||
it('keeps existing markdown structure intact', () => {
|
||||
const input = [
|
||||
'### Features',
|
||||
'- 新增在线更新',
|
||||
'',
|
||||
'### Fixes',
|
||||
'- 修复版本检查',
|
||||
].join('\n')
|
||||
|
||||
expect(normalizeReleaseNotesForDisplay(input)).toBe(input)
|
||||
})
|
||||
|
||||
it('converts plain multi-section notes into markdown sections', () => {
|
||||
const input = [
|
||||
'Features',
|
||||
'新增在线更新弹窗',
|
||||
'支持选择历史版本',
|
||||
'',
|
||||
'问题修复',
|
||||
'修复下载进度显示不准',
|
||||
'修复版本详情跳转错误',
|
||||
].join('\n')
|
||||
|
||||
expect(normalizeReleaseNotesForDisplay(input)).toBe([
|
||||
'### Features',
|
||||
'- 新增在线更新弹窗',
|
||||
'- 支持选择历史版本',
|
||||
'',
|
||||
'### 问题修复',
|
||||
'- 修复下载进度显示不准',
|
||||
'- 修复版本详情跳转错误',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('does not over-normalize plain paragraph text', () => {
|
||||
const input = '这次更新主要修复了在线更新流程中的代理问题,并优化了历史版本切换体验。'
|
||||
expect(normalizeReleaseNotesForDisplay(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,8 @@ function updateStatus(overrides: Partial<CheckUpdateResponse> = {}): CheckUpdate
|
||||
current_version: '0.7.0-rc27',
|
||||
latest_version: null,
|
||||
has_update: false,
|
||||
updatable: false,
|
||||
update_blocker: null,
|
||||
release_url: null,
|
||||
release_notes: null,
|
||||
published_at: null,
|
||||
|
||||
@@ -1,35 +1,74 @@
|
||||
const COMPACT_NUMBER_UNITS = [
|
||||
{ value: 1_000_000_000_000, suffix: 'T' },
|
||||
{ value: 1_000_000_000, suffix: 'B' },
|
||||
{ value: 1_000_000, suffix: 'M' },
|
||||
{ value: 1_000, suffix: 'K' },
|
||||
] as const
|
||||
|
||||
interface CompactNumberOptions {
|
||||
fractionDigits?: number
|
||||
nullLabel?: string
|
||||
}
|
||||
|
||||
function trimTrailingDecimalZeros(value: string): string {
|
||||
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
|
||||
}
|
||||
|
||||
function compactFractionDigits(scaled: number, fixedFractionDigits?: number): number {
|
||||
if (fixedFractionDigits !== undefined) return fixedFractionDigits
|
||||
if (scaled >= 100) return 0
|
||||
if (scaled >= 10) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
function formatCompactScaledValue(
|
||||
absValue: number,
|
||||
unitIndex: number,
|
||||
fixedFractionDigits?: number,
|
||||
): string {
|
||||
const unit = COMPACT_NUMBER_UNITS[unitIndex]
|
||||
const scaled = absValue / unit.value
|
||||
const fractionDigits = compactFractionDigits(scaled, fixedFractionDigits)
|
||||
const rounded = Number(scaled.toFixed(fractionDigits))
|
||||
|
||||
if (rounded >= 1000 && unitIndex > 0) {
|
||||
return formatCompactScaledValue(absValue, unitIndex - 1, fixedFractionDigits)
|
||||
}
|
||||
|
||||
return `${trimTrailingDecimalZeros(scaled.toFixed(fractionDigits))}${unit.suffix}`
|
||||
}
|
||||
|
||||
export function formatCompactNumber(
|
||||
num: number | undefined | null,
|
||||
options: CompactNumberOptions = {},
|
||||
): string {
|
||||
if (num === undefined || num === null) {
|
||||
return options.nullLabel ?? '0'
|
||||
}
|
||||
|
||||
const value = Number(num)
|
||||
if (!Number.isFinite(value)) {
|
||||
return options.nullLabel ?? '0'
|
||||
}
|
||||
|
||||
const sign = value < 0 ? '-' : ''
|
||||
const absValue = Math.abs(value)
|
||||
|
||||
if (absValue < 1_000) {
|
||||
return `${sign}${Number.isInteger(absValue) ? absValue.toString() : trimTrailingDecimalZeros(absValue.toFixed(1))}`
|
||||
}
|
||||
|
||||
const unitIndex = COMPACT_NUMBER_UNITS.findIndex(unit => absValue >= unit.value)
|
||||
if (unitIndex === -1) {
|
||||
return `${sign}${Math.round(absValue)}`
|
||||
}
|
||||
|
||||
return `${sign}${formatCompactScaledValue(absValue, unitIndex, options.fractionDigits)}`
|
||||
}
|
||||
|
||||
// Token formatting - intelligent display based on value size
|
||||
export function formatTokens(num: number | undefined | null): string {
|
||||
if (num === undefined || num === null || num === 0) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
// For very small values (< 1000), show as is without unit
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// For values 1K-999K, show in thousands
|
||||
if (num < 1000000) {
|
||||
const thousands = num / 1000
|
||||
if (thousands >= 100) {
|
||||
return `${Math.round(thousands) }K`
|
||||
} else if (thousands >= 10) {
|
||||
return `${thousands.toFixed(1) }K`
|
||||
} else {
|
||||
return `${thousands.toFixed(2) }K`
|
||||
}
|
||||
}
|
||||
|
||||
// For values >= 1M, show in millions
|
||||
const millions = num / 1000000
|
||||
if (millions >= 100) {
|
||||
return `${Math.round(millions) }M`
|
||||
} else if (millions >= 10) {
|
||||
return `${millions.toFixed(1) }M`
|
||||
} else {
|
||||
return `${millions.toFixed(2) }M`
|
||||
}
|
||||
return formatCompactNumber(num)
|
||||
}
|
||||
|
||||
// Currency formatting with high precision for small values
|
||||
@@ -135,12 +174,7 @@ export function formatCost(cost: number | null | undefined): string {
|
||||
|
||||
// Usage count formatting (compact display for large numbers)
|
||||
export function formatUsageCount(count: number): string {
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1)}M`
|
||||
} else if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}K`
|
||||
}
|
||||
return count.toString()
|
||||
return formatCompactNumber(count, { fractionDigits: 1 })
|
||||
}
|
||||
|
||||
// Format remaining time from unix timestamp
|
||||
|
||||
@@ -422,19 +422,13 @@ function getChatGPTWebQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
if (!window) return normalizeText(quota.label)
|
||||
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0 && window.remaining_value <= 0) {
|
||||
return `生图剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
if (remainingPercent != null) {
|
||||
if (typeof window.used_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
return `生图剩余 ${formatPercent(remainingPercent)} (${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)})`
|
||||
}
|
||||
return `生图剩余 ${formatPercent(remainingPercent)}`
|
||||
}
|
||||
|
||||
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
|
||||
return `生图剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
|
||||
}
|
||||
if (remainingPercent != null) {
|
||||
return `生图剩余 ${formatPercent(remainingPercent)}`
|
||||
}
|
||||
|
||||
if (typeof window.remaining_value === 'number') {
|
||||
return `生图剩余 ${formatQuotaValue(window.remaining_value)}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
const AUTO_GENERATED_RELEASE_SECTION_PATTERNS = [
|
||||
/^#{1,6}\s+what'?s changed\s*$/i,
|
||||
/^#{1,6}\s+new contributors\s*$/i,
|
||||
/^\*\*full changelog\*\*:/i,
|
||||
]
|
||||
|
||||
const MARKDOWN_STRUCTURE_PATTERNS = [
|
||||
/^#{1,6}\s+\S/,
|
||||
/^\s*[-*+]\s+\S/,
|
||||
/^\s*\d+\.\s+\S/,
|
||||
/^\s*>\s+\S/,
|
||||
/^\s*```/,
|
||||
/^\s*---+\s*$/,
|
||||
/^\s*\|(?:[^|]+\|)+\s*$/,
|
||||
/`[^`]+`/,
|
||||
/\[[^\]]+\]\([^)]+\)/,
|
||||
]
|
||||
|
||||
const SENTENCE_END_PUNCTUATION = /[,。,.!?!?;;]$/
|
||||
const SECTION_HEADING_SUFFIX = /[::]\s*$/
|
||||
const URL_PATTERN = /https?:\/\/|www\./i
|
||||
const BRACKET_PREFIX_PATTERN = /^[\[((【<]/
|
||||
const SECTION_HEADING_TEXT_PATTERN = /[\u3400-\u9FFFA-Za-z]/
|
||||
|
||||
function isStructuredMarkdownLine(line: string): boolean {
|
||||
return MARKDOWN_STRUCTURE_PATTERNS.some((pattern) => pattern.test(line))
|
||||
}
|
||||
|
||||
function normalizeSectionHeading(line: string): string {
|
||||
return line.replace(SECTION_HEADING_SUFFIX, '').trim()
|
||||
}
|
||||
|
||||
function looksLikeSectionHeading(line: string): boolean {
|
||||
const trimmed = normalizeSectionHeading(line)
|
||||
|
||||
if (!trimmed) return false
|
||||
if (trimmed.length > 24) return false
|
||||
if (URL_PATTERN.test(trimmed)) return false
|
||||
if (BRACKET_PREFIX_PATTERN.test(trimmed)) return false
|
||||
if (isStructuredMarkdownLine(trimmed)) return false
|
||||
if (SENTENCE_END_PUNCTUATION.test(trimmed)) return false
|
||||
if (!SECTION_HEADING_TEXT_PATTERN.test(trimmed)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectConfirmedHeadingIndexes(lines: string[]): Set<number> {
|
||||
const candidates = lines
|
||||
.map((line, index) => {
|
||||
if (!looksLikeSectionHeading(line.trim())) return -1
|
||||
if (index === 0) return index
|
||||
return lines[index - 1].trim() === '' ? index : -1
|
||||
})
|
||||
.filter((index) => index !== -1)
|
||||
|
||||
const confirmed = new Set<number>()
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const current = candidates[i]
|
||||
const next = i + 1 < candidates.length ? candidates[i + 1] : lines.length
|
||||
|
||||
for (let cursor = current + 1; cursor < next; cursor += 1) {
|
||||
const content = lines[cursor].trim()
|
||||
if (!content) continue
|
||||
confirmed.add(current)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return confirmed
|
||||
}
|
||||
|
||||
function collapseBlankLines(lines: string[]): string {
|
||||
return lines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function trimReleaseNotesForDisplay(notes: string | null | undefined): string {
|
||||
if (!notes) return ''
|
||||
|
||||
const normalized = notes.replace(/\r\n?/g, '\n').trim()
|
||||
if (!normalized) return ''
|
||||
|
||||
const lines = normalized.split('\n')
|
||||
const cutoff = lines.findIndex((line) => {
|
||||
const trimmed = line.trim()
|
||||
return AUTO_GENERATED_RELEASE_SECTION_PATTERNS.some((pattern) => pattern.test(trimmed))
|
||||
})
|
||||
|
||||
if (cutoff === -1) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return lines.slice(0, cutoff).join('\n').trim()
|
||||
}
|
||||
|
||||
export function normalizeReleaseNotesForDisplay(notes: string | null | undefined): string {
|
||||
const trimmed = trimReleaseNotesForDisplay(notes)
|
||||
if (!trimmed) return ''
|
||||
|
||||
const lines = trimmed.split('\n')
|
||||
if (lines.some((line) => isStructuredMarkdownLine(line.trim()))) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const headingIndexes = collectConfirmedHeadingIndexes(lines)
|
||||
if (headingIndexes.size < 2) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const normalized: string[] = []
|
||||
let insideSection = false
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const trimmedLine = lines[index].trim()
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (normalized.length > 0 && normalized[normalized.length - 1] !== '') {
|
||||
normalized.push('')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (headingIndexes.has(index)) {
|
||||
if (normalized.length > 0 && normalized[normalized.length - 1] !== '') {
|
||||
normalized.push('')
|
||||
}
|
||||
normalized.push(`### ${normalizeSectionHeading(trimmedLine)}`)
|
||||
insideSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (insideSection) {
|
||||
normalized.push(`- ${trimmedLine.replace(/^[-*+•]\s*/, '')}`)
|
||||
continue
|
||||
}
|
||||
|
||||
normalized.push(trimmedLine)
|
||||
}
|
||||
|
||||
return collapseBlankLines(normalized)
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export function buildUpdateErrorStatus(
|
||||
current_version: previousStatus?.current_version || '',
|
||||
latest_version: null,
|
||||
has_update: false,
|
||||
updatable: false,
|
||||
update_blocker: null,
|
||||
release_url: null,
|
||||
release_notes: null,
|
||||
published_at: null,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
|
||||
export function walletStatusLabel(status: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
active: '正常',
|
||||
@@ -41,13 +43,8 @@ export function dailyUsageCategoryLabel(isToday = false): string {
|
||||
|
||||
export function formatTokenCount(value: number | null | undefined): string {
|
||||
const amount = Number(value ?? 0)
|
||||
if (amount >= 1_000_000) {
|
||||
return `${(amount / 1_000_000).toFixed(amount >= 10_000_000 ? 0 : 1)}M`
|
||||
}
|
||||
if (amount >= 1_000) {
|
||||
return `${(amount / 1_000).toFixed(amount >= 10_000 ? 0 : 1)}K`
|
||||
}
|
||||
return `${Math.round(amount)}`
|
||||
if (!Number.isFinite(amount) || amount <= 0) return '0'
|
||||
return formatCompactNumber(Math.round(amount), { fractionDigits: 1 })
|
||||
}
|
||||
|
||||
export function walletTransactionReasonLabel(reasonCode: string | null | undefined): string {
|
||||
|
||||
Reference in New Issue
Block a user