codex额度查看工具开发完成1.0版本
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Codex Halo
|
||||||
|
|
||||||
|
使用 Vue 3、JavaScript 与 Electron 开发的 Windows Codex 本地额度悬浮仪表。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 从 `%USERPROFILE%\.codex\sessions` 读取 5 小时与 7 天额度快照
|
||||||
|
- 监听 Codex JSONL 日志变化并自动刷新
|
||||||
|
- 透明无边框、始终置顶、可拖动悬浮窗口
|
||||||
|
- 系统托盘、手动刷新和窗口位置记忆
|
||||||
|
- 纯 SVG/CSS 原创界面,不依赖图片资源
|
||||||
|
- 完全本地运行,不需要服务器或数据库
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器单独预览页面:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
生成真实 Electron 窗口预览图:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run capture
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试与构建
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物会生成在 `release` 目录。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<rect x="16" y="16" width="224" height="224" rx="60" fill="#3157F6"/>
|
||||||
|
<rect x="60" y="62" width="38" height="132" rx="19" fill="#FFFFFF"/>
|
||||||
|
<rect x="109" y="94" width="38" height="100" rx="19" fill="#FFFFFF" fill-opacity="0.88"/>
|
||||||
|
<rect x="158" y="126" width="38" height="68" rx="19" fill="#FFFFFF" fill-opacity="0.68"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 397 B |
Binary file not shown.
|
After Width: | Height: | Size: 393 B |
@@ -0,0 +1,344 @@
|
|||||||
|
import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, systemPreferences, Tray } from 'electron'
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { UsageReader } from './usage-reader.js'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const SINGLE_TILE_WIDTH = 120
|
||||||
|
const DOUBLE_TILE_WIDTH = 236
|
||||||
|
const WINDOW_HEIGHT = 60
|
||||||
|
const DOCK_WIDTH = 12
|
||||||
|
const DOCK_TRIGGER_DISTANCE = 16
|
||||||
|
const DOCK_ANIMATION_DURATION = 210
|
||||||
|
const screenshotArgument = process.argv.find((argument) => argument.startsWith('--screenshot='))
|
||||||
|
const screenshotPath = screenshotArgument?.slice('--screenshot='.length)
|
||||||
|
|
||||||
|
if (screenshotPath) {
|
||||||
|
app.setPath('userData', path.join(os.tmpdir(), `codex-halo-preview-${process.pid}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainWindow
|
||||||
|
let tray
|
||||||
|
let usageReader
|
||||||
|
let isQuitting = false
|
||||||
|
let saveBoundsTimer
|
||||||
|
let dragState = null
|
||||||
|
let normalWindowWidth = SINGLE_TILE_WIDTH
|
||||||
|
let isDocked = false
|
||||||
|
let isDockCollapsed = false
|
||||||
|
let dockTimer
|
||||||
|
let dockAnimationTimer
|
||||||
|
|
||||||
|
function settingsPath() {
|
||||||
|
return path.join(app.getPath('userData'), 'window-state.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWindowState() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(settingsPath(), 'utf8'))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisibleOnAnyDisplay(bounds) {
|
||||||
|
return screen.getAllDisplays().some(({ workArea }) => {
|
||||||
|
const horizontal = bounds.x < workArea.x + workArea.width && bounds.x + bounds.width > workArea.x
|
||||||
|
const vertical = bounds.y < workArea.y + workArea.height && bounds.y + bounds.height > workArea.y
|
||||||
|
return horizontal && vertical
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialBounds() {
|
||||||
|
const saved = readWindowState()
|
||||||
|
if (saved && isVisibleOnAnyDisplay(saved)) {
|
||||||
|
return {
|
||||||
|
width: SINGLE_TILE_WIDTH,
|
||||||
|
height: WINDOW_HEIGHT,
|
||||||
|
x: saved.x + Math.max(0, saved.width - SINGLE_TILE_WIDTH),
|
||||||
|
y: saved.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workArea } = screen.getPrimaryDisplay()
|
||||||
|
return {
|
||||||
|
width: SINGLE_TILE_WIDTH,
|
||||||
|
height: WINDOW_HEIGHT,
|
||||||
|
x: workArea.x + workArea.width - SINGLE_TILE_WIDTH - 28,
|
||||||
|
y: workArea.y + 28,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeForSnapshot(snapshot) {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||||
|
const now = Date.now()
|
||||||
|
const hasShortWindow = snapshot.shortWindow?.resetsAt > now
|
||||||
|
const hasWeekWindow = snapshot.weekWindow?.resetsAt > now
|
||||||
|
const width = hasShortWindow && hasWeekWindow ? DOUBLE_TILE_WIDTH : SINGLE_TILE_WIDTH
|
||||||
|
const bounds = mainWindow.getBounds()
|
||||||
|
normalWindowWidth = width
|
||||||
|
|
||||||
|
if (isDocked) {
|
||||||
|
clearInterval(dockAnimationTimer)
|
||||||
|
const { workArea } = screen.getDisplayMatching(bounds)
|
||||||
|
const dockWidth = isDockCollapsed ? DOCK_WIDTH : normalWindowWidth
|
||||||
|
mainWindow.setBounds({
|
||||||
|
x: workArea.x + workArea.width - dockWidth,
|
||||||
|
y: bounds.y,
|
||||||
|
width: dockWidth,
|
||||||
|
height: WINDOW_HEIGHT,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bounds.width !== width || bounds.height !== WINDOW_HEIGHT) {
|
||||||
|
mainWindow.setBounds({
|
||||||
|
x: bounds.x + bounds.width - width,
|
||||||
|
y: bounds.y,
|
||||||
|
width,
|
||||||
|
height: WINDOW_HEIGHT,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveWindowState() {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed() || isDocked) return
|
||||||
|
clearTimeout(saveBoundsTimer)
|
||||||
|
saveBoundsTimer = setTimeout(() => {
|
||||||
|
if (isDocked) return
|
||||||
|
writeFileSync(settingsPath(), JSON.stringify(mainWindow.getBounds()))
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDockCollapsed(collapsed, animated = true) {
|
||||||
|
if (!isDocked || !mainWindow || mainWindow.isDestroyed()) return
|
||||||
|
const bounds = mainWindow.getBounds()
|
||||||
|
const { workArea } = screen.getDisplayMatching(bounds)
|
||||||
|
const targetWidth = collapsed ? DOCK_WIDTH : normalWindowWidth
|
||||||
|
const rightEdge = workArea.x + workArea.width
|
||||||
|
const y = Math.max(
|
||||||
|
workArea.y,
|
||||||
|
Math.min(bounds.y, workArea.y + workArea.height - WINDOW_HEIGHT),
|
||||||
|
)
|
||||||
|
isDockCollapsed = collapsed
|
||||||
|
clearInterval(dockAnimationTimer)
|
||||||
|
|
||||||
|
if (!animated || systemPreferences.getAnimationSettings().prefersReducedMotion) {
|
||||||
|
mainWindow.setBounds({ x: rightEdge - targetWidth, y, width: targetWidth, height: WINDOW_HEIGHT })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startWidth = bounds.width
|
||||||
|
const startedAt = Date.now()
|
||||||
|
dockAnimationTimer = setInterval(() => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
clearInterval(dockAnimationTimer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.min(1, (Date.now() - startedAt) / DOCK_ANIMATION_DURATION)
|
||||||
|
const eased = 1 - Math.pow(1 - elapsed, 3)
|
||||||
|
const width = Math.round(startWidth + (targetWidth - startWidth) * eased)
|
||||||
|
mainWindow.setBounds({ x: rightEdge - width, y, width, height: WINDOW_HEIGHT })
|
||||||
|
|
||||||
|
if (elapsed === 1) clearInterval(dockAnimationTimer)
|
||||||
|
}, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockAtRightEdge() {
|
||||||
|
const bounds = mainWindow.getBounds()
|
||||||
|
const { workArea } = screen.getDisplayMatching(bounds)
|
||||||
|
const rightEdge = workArea.x + workArea.width
|
||||||
|
if (bounds.x + bounds.width < rightEdge - DOCK_TRIGGER_DISTANCE) return
|
||||||
|
isDocked = true
|
||||||
|
setDockCollapsed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrayImage() {
|
||||||
|
const size = 40
|
||||||
|
const bitmap = Buffer.alloc(size * size * 4)
|
||||||
|
|
||||||
|
function isInsideRoundedRect(x, y, left, top, right, bottom, radius) {
|
||||||
|
const nearestX = Math.max(left + radius, Math.min(x, right - radius))
|
||||||
|
const nearestY = Math.max(top + radius, Math.min(y, bottom - radius))
|
||||||
|
return Math.hypot(x - nearestX, y - nearestY) <= radius
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
const offset = (y * size + x) * 4
|
||||||
|
const isTile = isInsideRoundedRect(x + 0.5, y + 0.5, 4, 4, 36, 36, 9)
|
||||||
|
const isTallBar = isInsideRoundedRect(x + 0.5, y + 0.5, 13, 12, 18, 29, 2.5)
|
||||||
|
const isShortBar = isInsideRoundedRect(x + 0.5, y + 0.5, 23, 19, 28, 29, 2.5)
|
||||||
|
|
||||||
|
if (isTile) {
|
||||||
|
// Bitmap pixels use BGRA order. This matches the widget's cobalt-blue identity.
|
||||||
|
bitmap[offset] = 246
|
||||||
|
bitmap[offset + 1] = 87
|
||||||
|
bitmap[offset + 2] = 49
|
||||||
|
bitmap[offset + 3] = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTallBar || isShortBar) {
|
||||||
|
bitmap[offset] = 255
|
||||||
|
bitmap[offset + 1] = 255
|
||||||
|
bitmap[offset + 2] = 255
|
||||||
|
bitmap[offset + 3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nativeImage
|
||||||
|
.createFromBitmap(bitmap, { width: size, height: size, scaleFactor: 1 })
|
||||||
|
.resize({ width: 20, height: 20, quality: 'best' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWindow() {
|
||||||
|
if (!mainWindow) return
|
||||||
|
mainWindow.showInactive()
|
||||||
|
mainWindow.moveTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function trayMenu() {
|
||||||
|
return Menu.buildFromTemplate([
|
||||||
|
{ label: '重新读取', click: () => usageReader.refresh() },
|
||||||
|
{
|
||||||
|
label: '始终置顶',
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: mainWindow?.isAlwaysOnTop() ?? true,
|
||||||
|
click: ({ checked }) => mainWindow?.setAlwaysOnTop(checked, 'floating'),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '退出',
|
||||||
|
click: () => {
|
||||||
|
isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
...initialBounds(),
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
show: false,
|
||||||
|
resizable: false,
|
||||||
|
hasShadow: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.setAlwaysOnTop(true, 'floating')
|
||||||
|
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: false })
|
||||||
|
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
|
||||||
|
else mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
showWindow()
|
||||||
|
usageReader.refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.once('did-finish-load', async () => {
|
||||||
|
if (screenshotPath) {
|
||||||
|
showWindow()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||||
|
mainWindow.webContents.invalidate()
|
||||||
|
const image = await mainWindow.webContents.capturePage()
|
||||||
|
writeFileSync(path.resolve(screenshotPath), image.toPNG())
|
||||||
|
isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('move', saveWindowState)
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!isQuitting) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow.hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.on('context-menu', () => trayMenu().popup({ window: mainWindow }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTray() {
|
||||||
|
tray = new Tray(createTrayImage())
|
||||||
|
tray.setToolTip('Codex Halo · 本地额度仪表')
|
||||||
|
tray.setContextMenu(trayMenu())
|
||||||
|
tray.on('double-click', showWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSingleInstanceLock = screenshotPath || app.requestSingleInstanceLock()
|
||||||
|
if (!hasSingleInstanceLock) app.quit()
|
||||||
|
|
||||||
|
app.on('second-instance', showWindow)
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
isQuitting = true
|
||||||
|
clearInterval(dockAnimationTimer)
|
||||||
|
usageReader?.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
const sessionsPath = path.join(os.homedir(), '.codex', 'sessions')
|
||||||
|
usageReader = new UsageReader(sessionsPath)
|
||||||
|
usageReader.on('snapshot', (snapshot) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
resizeForSnapshot(snapshot)
|
||||||
|
mainWindow.webContents.send('usage:snapshot', snapshot)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
usageReader.start()
|
||||||
|
|
||||||
|
ipcMain.handle('usage:get', () => usageReader.readLatest())
|
||||||
|
ipcMain.handle('usage:refresh', () => usageReader.refresh())
|
||||||
|
ipcMain.on('window:drag-start', (event, point) => {
|
||||||
|
if (event.sender !== mainWindow?.webContents) return
|
||||||
|
clearTimeout(dockTimer)
|
||||||
|
if (isDocked) setDockCollapsed(false, false)
|
||||||
|
isDocked = false
|
||||||
|
isDockCollapsed = false
|
||||||
|
const bounds = mainWindow.getBounds()
|
||||||
|
dragState = { pointerX: point.x, pointerY: point.y, windowX: bounds.x, windowY: bounds.y }
|
||||||
|
})
|
||||||
|
ipcMain.on('window:drag-move', (event, point) => {
|
||||||
|
if (event.sender !== mainWindow?.webContents || !dragState) return
|
||||||
|
mainWindow.setPosition(
|
||||||
|
Math.round(dragState.windowX + point.x - dragState.pointerX),
|
||||||
|
Math.round(dragState.windowY + point.y - dragState.pointerY),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ipcMain.on('window:drag-end', () => {
|
||||||
|
dragState = null
|
||||||
|
dockAtRightEdge()
|
||||||
|
})
|
||||||
|
ipcMain.on('window:dock-expand', (event) => {
|
||||||
|
if (event.sender !== mainWindow?.webContents || !isDocked) return
|
||||||
|
clearTimeout(dockTimer)
|
||||||
|
setDockCollapsed(false)
|
||||||
|
})
|
||||||
|
ipcMain.on('window:dock-collapse', (event) => {
|
||||||
|
if (event.sender !== mainWindow?.webContents || !isDocked || dragState) return
|
||||||
|
clearTimeout(dockTimer)
|
||||||
|
dockTimer = setTimeout(() => setDockCollapsed(true), 180)
|
||||||
|
})
|
||||||
|
|
||||||
|
createWindow()
|
||||||
|
if (!screenshotPath) createTray()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// The tray owns the application lifecycle on Windows.
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('codexHalo', {
|
||||||
|
getUsage: () => ipcRenderer.invoke('usage:get'),
|
||||||
|
refreshUsage: () => ipcRenderer.invoke('usage:refresh'),
|
||||||
|
startWindowDrag: (point) => ipcRenderer.send('window:drag-start', point),
|
||||||
|
moveWindowDrag: (point) => ipcRenderer.send('window:drag-move', point),
|
||||||
|
endWindowDrag: () => ipcRenderer.send('window:drag-end'),
|
||||||
|
expandDock: () => ipcRenderer.send('window:dock-expand'),
|
||||||
|
collapseDock: () => ipcRenderer.send('window:dock-collapse'),
|
||||||
|
onUsage: (callback) => {
|
||||||
|
const listener = (_event, snapshot) => callback(snapshot)
|
||||||
|
ipcRenderer.on('usage:snapshot', listener)
|
||||||
|
return () => ipcRenderer.removeListener('usage:snapshot', listener)
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { closeSync, existsSync, fstatSync, openSync, readSync, readdirSync, statSync, watch } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export const SHORT_WINDOW_MINUTES = 300
|
||||||
|
export const WEEK_WINDOW_MINUTES = 10080
|
||||||
|
|
||||||
|
const MAX_FILES_TO_SCAN = 160
|
||||||
|
const TAIL_BYTES_PER_FILE = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUsageLine(line, sourceFile = '') {
|
||||||
|
if (!line.includes('"rate_limits"') || !line.includes('"token_count"')) return []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const root = JSON.parse(line)
|
||||||
|
if (root?.type !== 'event_msg' || root?.payload?.type !== 'token_count') return []
|
||||||
|
|
||||||
|
const observedAt = Number.isNaN(Date.parse(root.timestamp))
|
||||||
|
? Date.now()
|
||||||
|
: Date.parse(root.timestamp)
|
||||||
|
|
||||||
|
return ['primary', 'secondary'].flatMap((key) => {
|
||||||
|
const window = root.payload.rate_limits?.[key]
|
||||||
|
const windowMinutes = Number(window?.window_minutes)
|
||||||
|
const usedPercent = Number(window?.used_percent)
|
||||||
|
const resetsAtSeconds = Number(window?.resets_at)
|
||||||
|
|
||||||
|
if (![SHORT_WINDOW_MINUTES, WEEK_WINDOW_MINUTES].includes(windowMinutes)) return []
|
||||||
|
if (![usedPercent, resetsAtSeconds].every(Number.isFinite)) return []
|
||||||
|
|
||||||
|
return [{
|
||||||
|
windowMinutes,
|
||||||
|
usedPercent: clamp(usedPercent, 0, 100),
|
||||||
|
remainingPercent: clamp(100 - usedPercent, 0, 100),
|
||||||
|
resetsAt: resetsAtSeconds * 1000,
|
||||||
|
observedAt,
|
||||||
|
sourceFile,
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRolloutFiles(rootPath) {
|
||||||
|
const results = []
|
||||||
|
const pending = [rootPath]
|
||||||
|
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const directory = pending.pop()
|
||||||
|
let entries
|
||||||
|
|
||||||
|
try {
|
||||||
|
entries = readdirSync(directory, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(directory, entry.name)
|
||||||
|
if (entry.isDirectory()) pending.push(fullPath)
|
||||||
|
else if (entry.isFile() && /^rollout-.*\.jsonl$/i.test(entry.name)) results.push(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
.map((filePath) => {
|
||||||
|
try {
|
||||||
|
return { filePath, modifiedAt: statSync(filePath).mtimeMs }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.modifiedAt - a.modifiedAt)
|
||||||
|
.slice(0, MAX_FILES_TO_SCAN)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileTail(filePath) {
|
||||||
|
let descriptor
|
||||||
|
|
||||||
|
try {
|
||||||
|
descriptor = openSync(filePath, 'r')
|
||||||
|
const size = fstatSync(descriptor).size
|
||||||
|
const start = Math.max(0, size - TAIL_BYTES_PER_FILE)
|
||||||
|
const length = size - start
|
||||||
|
const buffer = Buffer.alloc(length)
|
||||||
|
readSync(descriptor, buffer, 0, length, start)
|
||||||
|
|
||||||
|
const lines = buffer.toString('utf8').split(/\r?\n/).filter(Boolean)
|
||||||
|
if (start > 0) lines.shift()
|
||||||
|
return lines
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) closeSync(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLatestUsage(sessionsPath) {
|
||||||
|
if (!existsSync(sessionsPath)) {
|
||||||
|
return {
|
||||||
|
shortWindow: null,
|
||||||
|
weekWindow: null,
|
||||||
|
status: '未找到 Codex 会话目录',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shortWindow = null
|
||||||
|
let weekWindow = null
|
||||||
|
|
||||||
|
for (const { filePath, modifiedAt } of collectRolloutFiles(sessionsPath)) {
|
||||||
|
for (const line of readFileTail(filePath)) {
|
||||||
|
for (const snapshot of parseUsageLine(line, filePath)) {
|
||||||
|
if (snapshot.windowMinutes === SHORT_WINDOW_MINUTES &&
|
||||||
|
(!shortWindow || snapshot.observedAt > shortWindow.observedAt)) {
|
||||||
|
shortWindow = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.windowMinutes === WEEK_WINDOW_MINUTES &&
|
||||||
|
(!weekWindow || snapshot.observedAt > weekWindow.observedAt)) {
|
||||||
|
weekWindow = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shortWindow && weekWindow &&
|
||||||
|
modifiedAt < shortWindow.observedAt && modifiedAt < weekWindow.observedAt) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (shortWindow?.resetsAt <= now) shortWindow = null
|
||||||
|
if (weekWindow?.resetsAt <= now) weekWindow = null
|
||||||
|
|
||||||
|
return {
|
||||||
|
shortWindow,
|
||||||
|
weekWindow,
|
||||||
|
status: shortWindow || weekWindow ? '已从本地日志同步' : '本地日志中暂无额度快照',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsageReader extends EventEmitter {
|
||||||
|
constructor(sessionsPath) {
|
||||||
|
super()
|
||||||
|
this.sessionsPath = sessionsPath
|
||||||
|
this.fileWatcher = null
|
||||||
|
this.refreshTimer = null
|
||||||
|
this.safetyTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
readLatest() {
|
||||||
|
return readLatestUsage(this.sessionsPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
const snapshot = this.readLatest()
|
||||||
|
this.emit('snapshot', snapshot)
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
requestRefresh() {
|
||||||
|
clearTimeout(this.refreshTimer)
|
||||||
|
this.refreshTimer = setTimeout(() => this.refresh(), 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (existsSync(this.sessionsPath)) {
|
||||||
|
try {
|
||||||
|
this.fileWatcher = watch(this.sessionsPath, { recursive: true }, (_event, fileName) => {
|
||||||
|
if (!fileName || /(^|[\\/])rollout-.*\.jsonl$/i.test(fileName)) this.requestRefresh()
|
||||||
|
})
|
||||||
|
this.fileWatcher.on('error', () => this.requestRefresh())
|
||||||
|
} catch {
|
||||||
|
this.fileWatcher = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.safetyTimer = setInterval(() => this.requestRefresh(), 15_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
clearTimeout(this.refreshTimer)
|
||||||
|
clearInterval(this.safetyTimer)
|
||||||
|
this.fileWatcher?.close()
|
||||||
|
this.fileWatcher = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# 发现与决策
|
||||||
|
|
||||||
|
## 需求
|
||||||
|
|
||||||
|
- 参考桌面 `codexorbit-1.2.3` 的功能开发 Vue 版本。
|
||||||
|
- 使用前端技术和 JavaScript。
|
||||||
|
- 样式重新设计,不使用原项目图片。
|
||||||
|
- 最终可构建为 Windows EXE。
|
||||||
|
|
||||||
|
## 原项目发现
|
||||||
|
|
||||||
|
- 原项目为 C#、WPF、.NET Framework 4.8、x64 WinExe。
|
||||||
|
- 读取 `%USERPROFILE%\.codex\sessions` 下的 `rollout-*.jsonl`。
|
||||||
|
- 从 `event_msg` / `token_count` / `rate_limits` 记录提取 primary 与 secondary 时间窗口。
|
||||||
|
- 5 小时窗口为 300 分钟,周窗口为 10080 分钟。
|
||||||
|
- 使用文件监听器更新额度;窗口位置写入本地配置文件。
|
||||||
|
- 不需要服务器或数据库。
|
||||||
|
|
||||||
|
## 组件地图
|
||||||
|
|
||||||
|
| 模块 | 单一职责 | 数据契约 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `App.vue` | 组合应用外壳与额度面板 | 使用 `useUsage`,向面板传入快照 |
|
||||||
|
| `QuotaTile.vue` | 以悬浮标签展示单个周期百分比和垂直刻度 | props: `value`, `label`, `tone` |
|
||||||
|
| `UsagePanel.vue` | 展示双窗口额度、状态与重置时间 | props: `snapshot`; emit: `refresh` |
|
||||||
|
| `useUsage.js` | 管理 IPC 订阅、刷新和演示数据 | 返回只读快照、连接状态和刷新动作 |
|
||||||
|
| Electron main | 窗口、托盘、日志解析和文件监听 | 通过 `usage:snapshot` IPC 推送快照 |
|
||||||
|
| Electron preload | 仅暴露受限桌面 API | `getUsage`, `refreshUsage`, `onUsage` |
|
||||||
|
|
||||||
|
## 视觉方向(第二版)
|
||||||
|
|
||||||
|
- “瑞士编辑排版 × 工业仪表”:奶油纸张色、炭黑信息块、朱红和酸绿色功能色。
|
||||||
|
- 去掉发光圆环,改为大数字、分段刻度和两种材质并置,提升扫读效率。
|
||||||
|
- 使用纯 CSS 几何元素,不使用图片;保留非对称圆角作为识别特征。
|
||||||
|
|
||||||
|
## 视觉方向(最终精简版)
|
||||||
|
|
||||||
|
- 去掉外层卡片、阴影、边框、标题、状态、重置时间与刷新按钮。
|
||||||
|
- 只保留圆形额度表盘、百分比和 `5h/7d` 标签。
|
||||||
|
- 5 小时快照已经过期时视为不存在,界面只显示周额度。
|
||||||
|
- Electron 窗口根据一枚或两枚表盘自动调整宽度,避免透明区域拦截桌面操作。
|
||||||
|
|
||||||
|
## 视觉方向(独立原创版)
|
||||||
|
|
||||||
|
- 放弃圆环参考,改为无边框、无阴影的悬浮额度标签。
|
||||||
|
- 以大号百分比、单色信号点和右侧垂直刻度表达额度,不增加业务文案。
|
||||||
|
- 周额度使用酸绿色,5小时额度使用珊瑚红;两者存在时并列,不存在时自动收缩。
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#08100f" />
|
||||||
|
<title>Codex Halo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+5571
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "codex-halo",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"description": "A lightweight Codex usage orbit for Windows.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "electron/main.js",
|
||||||
|
"author": "Local",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
|
||||||
|
"dev:web": "vite --host 127.0.0.1",
|
||||||
|
"test": "node --test",
|
||||||
|
"icon": "electron scripts/generate-icon.cjs",
|
||||||
|
"build:web": "vite build",
|
||||||
|
"capture": "vite build && electron . --screenshot=preview.png",
|
||||||
|
"build": "npm run test && npm run icon && vite build && electron-builder --win portable",
|
||||||
|
"pack": "npm run icon && vite build && electron-builder --win dir"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "dev.codexhalo.desktop",
|
||||||
|
"productName": "Codex Halo",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"asar": true,
|
||||||
|
"win": {
|
||||||
|
"target": "portable",
|
||||||
|
"icon": "build/icon.ico",
|
||||||
|
"artifactName": "CodexHalo-${version}.exe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"concurrently": "^9.0.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^37.0.0",
|
||||||
|
"electron-builder": "^26.0.0",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"wait-on": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
+51
@@ -0,0 +1,51 @@
|
|||||||
|
# 进度日志
|
||||||
|
|
||||||
|
## 2026-07-16
|
||||||
|
|
||||||
|
### 阶段 1:需求与发现
|
||||||
|
- **状态:** complete
|
||||||
|
- 已确认 Vue 3 + JavaScript + Electron 技术栈。
|
||||||
|
- 已分析 C# 版本的项目配置、界面方式、日志路径和数据窗口。
|
||||||
|
- 已确定组件边界与原创视觉方向。
|
||||||
|
- 创建文件:`task_plan.md`、`findings.md`、`progress.md`。
|
||||||
|
|
||||||
|
### 阶段 2:项目与界面实现
|
||||||
|
- **状态:** complete
|
||||||
|
- 创建 Vue 3 + Vite + Electron 基础工程。
|
||||||
|
- 实现 `OrbitGauge.vue`、`UsagePanel.vue` 和 `useUsage.js`。
|
||||||
|
- 完成深空导航仪器风格的纯 SVG/CSS 原创界面。
|
||||||
|
|
||||||
|
### 阶段 3:桌面能力实现
|
||||||
|
- **状态:** in_progress
|
||||||
|
- 实现 JSONL 尾部读取、额度解析、目录监听和 15 秒安全刷新。
|
||||||
|
- 实现透明置顶窗口、托盘、右键菜单、IPC 与窗口位置保存。
|
||||||
|
|
||||||
|
### 阶段 6:视觉重构
|
||||||
|
- **状态:** complete
|
||||||
|
- 根据用户反馈弃用第一版深色发光圆环视觉。
|
||||||
|
- 使用 frontend-design 技能确定“瑞士编辑排版 × 工业仪表”方向。
|
||||||
|
- 新增 `QuotaMeter.vue`,重构 `UsagePanel.vue`,未改变数据与桌面功能。
|
||||||
|
- 根据用户参考将展示进一步精简为 `QuotaDial.vue`,移除全部外层卡片信息。
|
||||||
|
- 日志读取层现在会过滤已过期的5小时和周额度快照。
|
||||||
|
- 单元测试 3 项全部通过,包含“过期5小时额度不展示”回归测试。
|
||||||
|
- Vue生产构建通过;Electron真实日志截图显示单枚 `7d` 周额度圆盘,无外层阴影和边框。
|
||||||
|
|
||||||
|
## 测试结果
|
||||||
|
|
||||||
|
| 测试 | 预期结果 | 实际结果 | 状态 |
|
||||||
|
|------|----------|----------|------|
|
||||||
|
| Node 环境 | Node/npm 可用 | Node 24.10.0,npm 11.6.1 | 通过 |
|
||||||
|
| 过期5小时额度 | `shortWindow` 返回 `null` | 回归测试通过 | 通过 |
|
||||||
|
| 精简版生产构建 | Vue构建成功 | 16个模块构建成功 | 通过 |
|
||||||
|
| 真实Electron预览 | 仅显示有效周额度圆盘 | 显示 `19% / 7d` | 通过 |
|
||||||
|
|
||||||
|
## 错误日志
|
||||||
|
|
||||||
|
| 错误 | 尝试次数 | 解决方案 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `npm install` 超过 120 秒被终止,未输出包错误 | 1 | 检查安装状态后延长时限继续 |
|
||||||
|
| `ModuleNotFoundError: playwright` | 2 | Python 运行时均未提供该包,改用内置 Node Playwright |
|
||||||
|
| Playwright 默认 Chromium 未安装 | 1 | 视觉脚本指定本机 Chrome 可执行文件 |
|
||||||
|
| 系统 Chrome 无头测试超时 | 1 | 停止进程,加入 Electron 原生截图验证模式 |
|
||||||
|
| 打包阶段 `win-unpacked.tmp` 重命名被拒绝 | 1 | 排查 Electron 残留进程及目录占用后重试 |
|
||||||
|
| 首个 portable 验证时未生成应用截图 | 1 | 检查发现预打包目录尚未注入应用代码,重新制作完整预打包目录 |
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
const { app, nativeImage } = require('electron')
|
||||||
|
const { writeFileSync } = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const projectRoot = path.join(__dirname, '..')
|
||||||
|
const sizes = [16, 24, 32, 48, 64, 128, 256]
|
||||||
|
|
||||||
|
function insideRoundedRect(x, y, left, top, right, bottom, radius) {
|
||||||
|
const nearestX = Math.max(left + radius, Math.min(x, right - radius))
|
||||||
|
const nearestY = Math.max(top + radius, Math.min(y, bottom - radius))
|
||||||
|
return Math.hypot(x - nearestX, y - nearestY) <= radius
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSourceImage() {
|
||||||
|
const size = 512
|
||||||
|
const bitmap = Buffer.alloc(size * size * 4)
|
||||||
|
const bars = [
|
||||||
|
[120, 124, 196, 388, 38, 255],
|
||||||
|
[218, 188, 294, 388, 38, 224],
|
||||||
|
[316, 252, 392, 388, 38, 173],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
for (let x = 0; x < size; x += 1) {
|
||||||
|
const offset = (y * size + x) * 4
|
||||||
|
if (insideRoundedRect(x + 0.5, y + 0.5, 32, 32, 480, 480, 120)) {
|
||||||
|
bitmap[offset] = 246
|
||||||
|
bitmap[offset + 1] = 87
|
||||||
|
bitmap[offset + 2] = 49
|
||||||
|
bitmap[offset + 3] = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = bars.find(([left, top, right, bottom, radius]) => (
|
||||||
|
insideRoundedRect(x + 0.5, y + 0.5, left, top, right, bottom, radius)
|
||||||
|
))
|
||||||
|
if (bar) {
|
||||||
|
const opacity = bar[5] / 255
|
||||||
|
bitmap[offset] = Math.round(246 + (255 - 246) * opacity)
|
||||||
|
bitmap[offset + 1] = Math.round(87 + (255 - 87) * opacity)
|
||||||
|
bitmap[offset + 2] = Math.round(49 + (255 - 49) * opacity)
|
||||||
|
bitmap[offset + 3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nativeImage.createFromBitmap(bitmap, { width: size, height: size, scaleFactor: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateIcon() {
|
||||||
|
const source = createSourceImage()
|
||||||
|
const images = sizes.map((size) => source.resize({ width: size, height: size, quality: 'best' }).toPNG())
|
||||||
|
const headerSize = 6 + images.length * 16
|
||||||
|
const header = Buffer.alloc(headerSize)
|
||||||
|
header.writeUInt16LE(0, 0)
|
||||||
|
header.writeUInt16LE(1, 2)
|
||||||
|
header.writeUInt16LE(images.length, 4)
|
||||||
|
|
||||||
|
let imageOffset = headerSize
|
||||||
|
images.forEach((image, index) => {
|
||||||
|
const entryOffset = 6 + index * 16
|
||||||
|
const size = sizes[index]
|
||||||
|
header.writeUInt8(size === 256 ? 0 : size, entryOffset)
|
||||||
|
header.writeUInt8(size === 256 ? 0 : size, entryOffset + 1)
|
||||||
|
header.writeUInt8(0, entryOffset + 2)
|
||||||
|
header.writeUInt8(0, entryOffset + 3)
|
||||||
|
header.writeUInt16LE(1, entryOffset + 4)
|
||||||
|
header.writeUInt16LE(32, entryOffset + 6)
|
||||||
|
header.writeUInt32LE(image.length, entryOffset + 8)
|
||||||
|
header.writeUInt32LE(imageOffset, entryOffset + 12)
|
||||||
|
imageOffset += image.length
|
||||||
|
})
|
||||||
|
|
||||||
|
writeFileSync(path.join(projectRoot, 'build', 'icon-preview.png'), images.at(-1))
|
||||||
|
writeFileSync(path.join(projectRoot, 'build', 'icon.ico'), Buffer.concat([header, ...images]))
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady()
|
||||||
|
.then(generateIcon)
|
||||||
|
.then(() => app.quit())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
app.exit(1)
|
||||||
|
})
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup>
|
||||||
|
import UsagePanel from '@/components/UsagePanel.vue'
|
||||||
|
import { useUsage } from '@/composables/useUsage'
|
||||||
|
|
||||||
|
const { snapshot } = useUsage()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UsagePanel :snapshot="snapshot" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
:root {
|
||||||
|
font-family: 'Microsoft YaHei UI', 'Segoe UI', sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: Number, default: 0 },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
accent: { type: String, default: 'amber' },
|
||||||
|
compact: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const radius = 42
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const normalizedValue = computed(() => Math.min(100, Math.max(0, props.value)))
|
||||||
|
const ringStyle = computed(() => ({
|
||||||
|
strokeDasharray: `${circumference} ${circumference}`,
|
||||||
|
strokeDashoffset: circumference * (1 - normalizedValue.value / 100),
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="gauge" :class="[`gauge--${accent}`, { 'gauge--compact': compact }]">
|
||||||
|
<svg class="gauge__svg" viewBox="0 0 100 100" aria-hidden="true">
|
||||||
|
<circle class="gauge__track" cx="50" cy="50" :r="radius" />
|
||||||
|
<circle class="gauge__ticks" cx="50" cy="50" r="47" />
|
||||||
|
<circle class="gauge__progress" cx="50" cy="50" :r="radius" :style="ringStyle" />
|
||||||
|
</svg>
|
||||||
|
<div class="gauge__content">
|
||||||
|
<strong class="gauge__value">{{ Math.round(normalizedValue) }}</strong>
|
||||||
|
<span class="gauge__unit">%</span>
|
||||||
|
<span class="gauge__label">{{ label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gauge {
|
||||||
|
--gauge-accent: #ffad42;
|
||||||
|
--gauge-glow: rgba(255, 173, 66, 0.38);
|
||||||
|
position: relative;
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge--mint {
|
||||||
|
--gauge-accent: #55e6bd;
|
||||||
|
--gauge-glow: rgba(85, 230, 189, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge--compact {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
filter: drop-shadow(0 0 9px var(--gauge-glow));
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__track,
|
||||||
|
.gauge__progress {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 5.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__track {
|
||||||
|
stroke: rgba(255, 255, 255, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__ticks {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(255, 255, 255, 0.13);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 0.8 5.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__progress {
|
||||||
|
stroke: var(--gauge-accent);
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__content {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 32px;
|
||||||
|
color: #f5f1e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__value {
|
||||||
|
font-family: Bahnschrift, 'Aptos Display', sans-serif;
|
||||||
|
font-size: 31px;
|
||||||
|
font-weight: 450;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__unit {
|
||||||
|
margin-left: 2px;
|
||||||
|
color: var(--gauge-accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge__label {
|
||||||
|
position: absolute;
|
||||||
|
top: 66px;
|
||||||
|
color: rgba(224, 231, 225, 0.48);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge--compact .gauge__track,
|
||||||
|
.gauge--compact .gauge__progress {
|
||||||
|
stroke-width: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge--compact .gauge__ticks {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge--compact .gauge__content {
|
||||||
|
padding-top: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge--compact .gauge__value {
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge--compact .gauge__unit {
|
||||||
|
font-size: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gauge--compact .gauge__label {
|
||||||
|
top: 35px;
|
||||||
|
font-size: 6px;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: Number, required: true },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
mark: { type: String, required: true },
|
||||||
|
tone: { type: String, default: 'week' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizedValue = computed(() => Math.min(100, Math.max(0, props.value)))
|
||||||
|
const roundedValue = computed(() => Math.round(normalizedValue.value))
|
||||||
|
const progressStyle = computed(() => ({
|
||||||
|
transform: `scaleX(${normalizedValue.value / 100})`,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="quota"
|
||||||
|
:class="`quota--${tone}`"
|
||||||
|
:aria-label="`${label}剩余${roundedValue}%`"
|
||||||
|
>
|
||||||
|
<div class="quota__mark" aria-hidden="true">{{ mark }}</div>
|
||||||
|
|
||||||
|
<div class="quota__content">
|
||||||
|
<div class="quota__reading">
|
||||||
|
<strong>{{ roundedValue }}</strong><span>%</span>
|
||||||
|
</div>
|
||||||
|
<div class="quota__track" aria-hidden="true">
|
||||||
|
<i :style="progressStyle"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="sr-only"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-label="`${label}剩余额度`"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
:aria-valuenow="roundedValue"
|
||||||
|
></span>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quota {
|
||||||
|
--accent: #3157f6;
|
||||||
|
display: flex;
|
||||||
|
width: 112px;
|
||||||
|
height: 52px;
|
||||||
|
flex: 0 0 112px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #17181c;
|
||||||
|
background: #f2f0e9;
|
||||||
|
border-radius: 15px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
animation: quota-enter 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota--short {
|
||||||
|
--accent: #eb6448;
|
||||||
|
background: #f4eee8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota__mark {
|
||||||
|
display: grid;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: 0 0 30px;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 9px;
|
||||||
|
font-family: Bahnschrift, 'Aptos Narrow', sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota__content {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota__reading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
font-family: Bahnschrift, 'Aptos Display', sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota__reading strong {
|
||||||
|
font-size: 25px;
|
||||||
|
font-weight: 620;
|
||||||
|
letter-spacing: -1.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota__reading span {
|
||||||
|
margin-left: 2px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota__track {
|
||||||
|
width: 44px;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #d3d0c7;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota__track i {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: inherit;
|
||||||
|
transform-origin: left center;
|
||||||
|
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes quota-enter {
|
||||||
|
from { opacity: 0; transform: translateY(3px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.quota { animation: none; }
|
||||||
|
.quota__track i { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import QuotaTile from './QuotaTile.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
snapshot: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const shortWindow = computed(() => props.snapshot?.shortWindow ?? null)
|
||||||
|
const weekWindow = computed(() => props.snapshot?.weekWindow ?? null)
|
||||||
|
const dockWindows = computed(() => [shortWindow.value, weekWindow.value].filter(Boolean))
|
||||||
|
|
||||||
|
let draggingPointer = null
|
||||||
|
|
||||||
|
function startDrag(event) {
|
||||||
|
const hitTarget = event.target.closest('.quota, .dock-strip')
|
||||||
|
if (!hitTarget || event.button !== 0 || !window.codexHalo?.startWindowDrag) return
|
||||||
|
draggingPointer = event.pointerId
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
|
window.codexHalo.startWindowDrag({ x: event.screenX, y: event.screenY })
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDrag(event) {
|
||||||
|
if (event.pointerId !== draggingPointer) return
|
||||||
|
window.codexHalo.moveWindowDrag({ x: event.screenX, y: event.screenY })
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(event) {
|
||||||
|
if (event.pointerId !== draggingPointer) return
|
||||||
|
draggingPointer = null
|
||||||
|
window.codexHalo.endWindowDrag()
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressStyle(value) {
|
||||||
|
const normalized = Math.min(100, Math.max(0, value))
|
||||||
|
return { transform: `scaleY(${normalized / 100})` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandDock() {
|
||||||
|
window.codexHalo?.expandDock?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseDock() {
|
||||||
|
window.codexHalo?.collapseDock?.()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main
|
||||||
|
class="panel"
|
||||||
|
@pointerdown="startDrag"
|
||||||
|
@pointermove="moveDrag"
|
||||||
|
@pointerup="endDrag"
|
||||||
|
@pointercancel="endDrag"
|
||||||
|
@pointerleave="collapseDock"
|
||||||
|
>
|
||||||
|
<QuotaTile
|
||||||
|
v-if="shortWindow"
|
||||||
|
:value="shortWindow.remainingPercent"
|
||||||
|
label="5 小时"
|
||||||
|
mark="5H"
|
||||||
|
tone="short"
|
||||||
|
/>
|
||||||
|
<QuotaTile
|
||||||
|
v-if="weekWindow"
|
||||||
|
:value="weekWindow.remainingPercent"
|
||||||
|
label="周限额"
|
||||||
|
mark="7D"
|
||||||
|
/>
|
||||||
|
<div class="dock-strip" aria-hidden="true" @pointerenter="expandDock">
|
||||||
|
<span v-for="quota in dockWindows" :key="quota.windowMinutes">
|
||||||
|
<i :style="progressStyle(quota.remainingPercent)"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
background: transparent;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.quota),
|
||||||
|
.dock-strip {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.quota:active),
|
||||||
|
.dock-strip:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 30px) {
|
||||||
|
.panel {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-strip {
|
||||||
|
display: flex;
|
||||||
|
width: 9px;
|
||||||
|
height: 52px;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 5px 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f2f0e9;
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-strip > span {
|
||||||
|
position: relative;
|
||||||
|
width: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #d3d0c7;
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-strip i {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: #3157f6;
|
||||||
|
border-radius: inherit;
|
||||||
|
transform-origin: center bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-strip > span:first-child:not(:last-child) i {
|
||||||
|
background: #eb6448;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { onMounted, onUnmounted, readonly, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
function demoSnapshot() {
|
||||||
|
const now = Date.now()
|
||||||
|
return {
|
||||||
|
shortWindow: null,
|
||||||
|
weekWindow: {
|
||||||
|
remainingPercent: 21,
|
||||||
|
usedPercent: 79,
|
||||||
|
resetsAt: now + 3 * 24 * 60 * 60 * 1000 + 8 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
status: '浏览器预览数据',
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsage() {
|
||||||
|
const snapshot = shallowRef(null)
|
||||||
|
const isRefreshing = shallowRef(false)
|
||||||
|
let unsubscribe
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!window.codexHalo) {
|
||||||
|
snapshot.value = demoSnapshot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing.value = true
|
||||||
|
try {
|
||||||
|
snapshot.value = await window.codexHalo.refreshUsage()
|
||||||
|
} finally {
|
||||||
|
isRefreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!window.codexHalo) {
|
||||||
|
snapshot.value = demoSnapshot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe = window.codexHalo.onUsage((nextSnapshot) => {
|
||||||
|
snapshot.value = nextSnapshot
|
||||||
|
isRefreshing.value = false
|
||||||
|
})
|
||||||
|
snapshot.value = await window.codexHalo.getUsage()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => unsubscribe?.())
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: readonly(snapshot),
|
||||||
|
isRefreshing: readonly(isRefreshing),
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Codex Orbit Vue 开发计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
使用 Vue 3、JavaScript 与 Electron 开发一个原创视觉的 Windows Codex 额度悬浮工具,不复用 C# 版本的图片资源。
|
||||||
|
|
||||||
|
## 当前阶段
|
||||||
|
阶段 6:视觉重构完成
|
||||||
|
|
||||||
|
## 阶段
|
||||||
|
|
||||||
|
### 阶段 1:需求与发现
|
||||||
|
- [x] 确认技术栈与用户约束
|
||||||
|
- [x] 检查原 C# 项目的日志读取方式
|
||||||
|
- [x] 记录组件边界与技术方案
|
||||||
|
- **状态:** complete
|
||||||
|
|
||||||
|
### 阶段 2:项目与界面实现
|
||||||
|
- [x] 创建 Vue 3 + Vite + Electron 工程
|
||||||
|
- [x] 实现原创悬浮仪表界面
|
||||||
|
- [x] 实现演示模式与响应式状态
|
||||||
|
- **状态:** complete
|
||||||
|
|
||||||
|
### 阶段 3:桌面能力实现
|
||||||
|
- [x] 解析 Codex 本地 JSONL 日志
|
||||||
|
- [x] 监听日志并通过 IPC 更新 Vue
|
||||||
|
- [x] 实现托盘、置顶、拖动、保存位置
|
||||||
|
- **状态:** in_progress
|
||||||
|
|
||||||
|
### 阶段 4:构建与验证
|
||||||
|
- [ ] 安装依赖并运行静态检查
|
||||||
|
- [ ] 构建前端与 Windows EXE
|
||||||
|
- [ ] 启动应用并进行视觉验证
|
||||||
|
- **状态:** pending
|
||||||
|
|
||||||
|
### 阶段 5:交付
|
||||||
|
- [ ] 检查输出文件和使用说明
|
||||||
|
- [ ] 更新进度与最终结果
|
||||||
|
- **状态:** pending
|
||||||
|
|
||||||
|
### 阶段 6:视觉重构
|
||||||
|
- [x] 确定新的编辑式工业仪表方向
|
||||||
|
- [x] 将圆环组件替换为分段额度组件
|
||||||
|
- [x] 重构面板排版、颜色、字体和动效
|
||||||
|
- [x] 根据用户参考进一步精简为纯额度表盘
|
||||||
|
- [x] 修复过期5小时额度仍然显示的问题
|
||||||
|
- [x] 构建并进行真实窗口视觉验证
|
||||||
|
- **状态:** complete
|
||||||
|
|
||||||
|
## 已做决策
|
||||||
|
|
||||||
|
| 决策 | 理由 |
|
||||||
|
|------|------|
|
||||||
|
| Vue 3 Composition API + JavaScript | 用户已确认希望使用 JavaScript |
|
||||||
|
| Electron + electron-builder | 提供透明窗口、托盘、本地文件和 EXE 打包能力 |
|
||||||
|
| SVG + CSS 原创建图 | 不复用原项目图片,保持界面轻量清晰 |
|
||||||
|
| 无服务器、无数据库 | 数据来自本机 Codex 会话日志 |
|
||||||
|
|
||||||
|
## 遇到的错误
|
||||||
|
|
||||||
|
| 错误 | 尝试次数 | 解决方案 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 首次 `npm install` 在 120 秒工具时限内未完成 | 1 | 保留已下载内容并使用更长时限继续安装 |
|
||||||
|
| 系统与内置 Python 均缺少 Playwright 模块 | 2 | 改用工作区已提供的 Node Playwright 包 |
|
||||||
|
| 内置 Playwright 缺少默认浏览器二进制 | 1 | 指定系统已安装的 Chrome 作为测试浏览器 |
|
||||||
|
| 系统 Chrome 无头视觉会话未正常退出 | 1 | 改用 Electron 自身的 `capturePage` 验证真实窗口 |
|
||||||
|
| electron-builder 重命名临时发布目录时遇到 Windows `EPERM` | 1 | 检查残留进程和发布目录锁后清理临时产物重试 |
|
||||||
|
| 从临时目录生成的首个 portable 仅含 Electron 空运行时 | 1 | 不交付该文件;补齐应用 `app.asar` 和产品 EXE 后重新封装验证 |
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { parseUsageLine, readLatestUsage } from '../electron/usage-reader.js'
|
||||||
|
|
||||||
|
test('parses primary and secondary Codex rate limit windows', () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
timestamp: '2026-07-16T08:00:00.000Z',
|
||||||
|
type: 'event_msg',
|
||||||
|
payload: {
|
||||||
|
type: 'token_count',
|
||||||
|
rate_limits: {
|
||||||
|
primary: { window_minutes: 300, used_percent: 32, resets_at: 1784203200 },
|
||||||
|
secondary: { window_minutes: 10080, used_percent: 59, resets_at: 1784808000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const snapshots = parseUsageLine(line, 'rollout-test.jsonl')
|
||||||
|
assert.equal(snapshots.length, 2)
|
||||||
|
assert.equal(snapshots[0].remainingPercent, 68)
|
||||||
|
assert.equal(snapshots[1].remainingPercent, 41)
|
||||||
|
assert.equal(snapshots[0].sourceFile, 'rollout-test.jsonl')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores malformed or unrelated lines', () => {
|
||||||
|
assert.deepEqual(parseUsageLine('{broken'), [])
|
||||||
|
assert.deepEqual(parseUsageLine(JSON.stringify({ type: 'event_msg', payload: { type: 'message' } })), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not expose an expired five-hour window', () => {
|
||||||
|
const sessionsPath = mkdtempSync(path.join(os.tmpdir(), 'codex-halo-test-'))
|
||||||
|
const now = Date.now()
|
||||||
|
const line = JSON.stringify({
|
||||||
|
timestamp: new Date(now).toISOString(),
|
||||||
|
type: 'event_msg',
|
||||||
|
payload: {
|
||||||
|
type: 'token_count',
|
||||||
|
rate_limits: {
|
||||||
|
primary: { window_minutes: 300, used_percent: 31, resets_at: Math.floor(now / 1000) - 30 },
|
||||||
|
secondary: { window_minutes: 10080, used_percent: 79, resets_at: Math.floor(now / 1000) + 3600 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(path.join(sessionsPath, 'rollout-test.jsonl'), line)
|
||||||
|
const snapshot = readLatestUsage(sessionsPath)
|
||||||
|
assert.equal(snapshot.shortWindow, null)
|
||||||
|
assert.equal(snapshot.weekWindow.remainingPercent, 21)
|
||||||
|
} finally {
|
||||||
|
rmSync(sessionsPath, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user