@juliantanx/aiusage-widget 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/README_zh.md +79 -0
- package/bin/launcher.js +40 -0
- package/dist/data.js +64 -0
- package/dist/main.js +200 -0
- package/dist/preload.js +12 -0
- package/dist/renderer/assets/index-2X4hbwJe.css +1 -0
- package/dist/renderer/assets/index-Bb9mog0W.js +1 -0
- package/dist/renderer/index.html +14 -0
- package/dist/ui.js +34 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @juliantanx/aiusage-widget
|
|
2
|
+
|
|
3
|
+
A lightweight system tray widget for [aiusage](https://github.com/juliantanx/aiusage) that shows your AI coding assistant token usage at a glance.
|
|
4
|
+
|
|
5
|
+
English | [中文](./README_zh.md)
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **System tray integration** — lives in your system tray, click to toggle a compact stats panel.
|
|
10
|
+
- **Today's token usage** — total tokens with input/output breakdown.
|
|
11
|
+
- **Monthly totals** — rolling token count for the current month.
|
|
12
|
+
- **Top model** — your most-used model today and its share.
|
|
13
|
+
- **Dashboard launcher** — one click to open the full aiusage web dashboard.
|
|
14
|
+
- **Auto-refresh** — updates every 60 seconds automatically.
|
|
15
|
+
- **Cross-platform** — Windows, macOS, and Linux.
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
- [aiusage](https://github.com/juliantanx/aiusage) CLI installed and data parsed (`aiusage parse`)
|
|
20
|
+
- Node.js >= 18
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g @juliantanx/aiusage-widget
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Start the widget (runs in background, adds a tray icon)
|
|
32
|
+
aiusage-widget
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The widget reads from `~/.aiusage/cache.db`. Make sure you have run `aiusage parse` at least once so the database exists.
|
|
36
|
+
|
|
37
|
+
### Running with PM2
|
|
38
|
+
|
|
39
|
+
To keep the widget running after closing the terminal and auto-start on boot:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g pm2 pm2-startup
|
|
43
|
+
|
|
44
|
+
pm2 start aiusage-widget -- --foreground
|
|
45
|
+
pm2 save
|
|
46
|
+
pm2-startup install
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
PM2 works on Windows, macOS, and Linux.
|
|
50
|
+
|
|
51
|
+
**Tray interactions:**
|
|
52
|
+
|
|
53
|
+
- **Left-click** — toggle the stats panel.
|
|
54
|
+
- **Right-click** — context menu with Show Panel, Refresh, and Quit.
|
|
55
|
+
|
|
56
|
+
**Panel actions:**
|
|
57
|
+
|
|
58
|
+
- Click **Open Full Dashboard** to launch `aiusage serve` and open the web dashboard in your browser.
|
|
59
|
+
|
|
60
|
+
## Build from Source
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/juliantanx/aiusage.git
|
|
64
|
+
cd aiusage
|
|
65
|
+
pnpm install
|
|
66
|
+
pnpm build
|
|
67
|
+
cd packages/widget
|
|
68
|
+
pnpm dev
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Tech Stack
|
|
72
|
+
|
|
73
|
+
- **Runtime:** Electron
|
|
74
|
+
- **UI:** Svelte + Vite
|
|
75
|
+
- **Database:** better-sqlite3 (reads aiusage's local SQLite database)
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
package/README_zh.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @juliantanx/aiusage-widget
|
|
2
|
+
|
|
3
|
+
一个轻量级的系统托盘小组件,用于 [aiusage](https://github.com/juliantanx/aiusage),可以快速查看 AI 编程助手的 token 用量。
|
|
4
|
+
|
|
5
|
+
[English](./README.md) | 中文
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- **系统托盘集成** — 常驻系统托盘,点击即可弹出简洁的统计面板。
|
|
10
|
+
- **今日 token 用量** — 总 token 数及输入/输出明细。
|
|
11
|
+
- **月度总量** — 当月累计 token 数。
|
|
12
|
+
- **热门模型** — 今日使用最多的模型及其占比。
|
|
13
|
+
- **仪表盘启动** — 一键打开完整的 aiusage Web 仪表盘。
|
|
14
|
+
- **自动刷新** — 每 60 秒自动更新数据。
|
|
15
|
+
- **跨平台** — 支持 Windows、macOS 和 Linux。
|
|
16
|
+
|
|
17
|
+
## 前置条件
|
|
18
|
+
|
|
19
|
+
- 已安装 [aiusage](https://github.com/juliantanx/aiusage) CLI 并完成数据解析(`aiusage parse`)
|
|
20
|
+
- Node.js >= 18
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g @juliantanx/aiusage-widget
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 使用
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# 启动组件(后台运行,添加系统托盘图标)
|
|
32
|
+
aiusage-widget
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
组件会读取 `~/.aiusage/cache.db`。请确保至少执行过一次 `aiusage parse`,使数据库文件存在。
|
|
36
|
+
|
|
37
|
+
### 使用 PM2 后台运行
|
|
38
|
+
|
|
39
|
+
如需关闭终端后保持组件运行,并实现开机自启:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g pm2 pm2-startup
|
|
43
|
+
|
|
44
|
+
pm2 start aiusage-widget -- --foreground
|
|
45
|
+
pm2 save
|
|
46
|
+
pm2-startup install
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
PM2 支持 Windows、macOS 和 Linux。
|
|
50
|
+
|
|
51
|
+
**托盘操作:**
|
|
52
|
+
|
|
53
|
+
- **左键点击** — 切换统计面板的显示/隐藏。
|
|
54
|
+
- **右键点击** — 弹出上下文菜单,包含显示面板、刷新和退出。
|
|
55
|
+
|
|
56
|
+
**面板操作:**
|
|
57
|
+
|
|
58
|
+
- 点击 **Open Full Dashboard** 可启动 `aiusage serve` 并在浏览器中打开 Web 仪表盘。
|
|
59
|
+
|
|
60
|
+
## 从源码构建
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/juliantanx/aiusage.git
|
|
64
|
+
cd aiusage
|
|
65
|
+
pnpm install
|
|
66
|
+
pnpm build
|
|
67
|
+
cd packages/widget
|
|
68
|
+
pnpm dev
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 技术栈
|
|
72
|
+
|
|
73
|
+
- **运行时:** Electron
|
|
74
|
+
- **UI:** Svelte + Vite
|
|
75
|
+
- **数据库:** better-sqlite3(读取 aiusage 的本地 SQLite 数据库)
|
|
76
|
+
|
|
77
|
+
## 许可证
|
|
78
|
+
|
|
79
|
+
MIT
|
package/bin/launcher.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { spawn } = require('child_process')
|
|
3
|
+
const { writeFileSync, mkdirSync } from 'fs'
|
|
4
|
+
const { homedir } = require('os')
|
|
5
|
+
const electron = require('electron')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const foreground = process.argv.includes('--foreground')
|
|
9
|
+
const aiusageDir = path.join(homedir(), '.aiusage')
|
|
10
|
+
const pidPath = path.join(aiusageDir, 'widget.pid')
|
|
11
|
+
|
|
12
|
+
if (foreground) {
|
|
13
|
+
// PM2 / service mode: run in foreground so the process manager can monitor it
|
|
14
|
+
const child = spawn(
|
|
15
|
+
String(electron),
|
|
16
|
+
[path.join(__dirname, '..', 'dist', 'main.js')],
|
|
17
|
+
{ stdio: 'inherit' }
|
|
18
|
+
)
|
|
19
|
+
child.on('exit', (code) => process.exit(code ?? 0))
|
|
20
|
+
process.on('SIGINT', () => child.kill('SIGINT'))
|
|
21
|
+
process.on('SIGTERM', () => child.kill('SIGTERM'))
|
|
22
|
+
} else {
|
|
23
|
+
// Normal mode: detach so closing the terminal doesn't kill the widget
|
|
24
|
+
const child = spawn(
|
|
25
|
+
String(electron),
|
|
26
|
+
[path.join(__dirname, '..', 'dist', 'main.js')],
|
|
27
|
+
{ detached: true, stdio: 'ignore' }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if (child.pid) {
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(aiusageDir, { recursive: true })
|
|
33
|
+
writeFileSync(pidPath, String(child.pid), { encoding: 'utf-8' })
|
|
34
|
+
} catch {
|
|
35
|
+
// Non-fatal: widget still starts, deduplication just won't work
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
child.unref()
|
|
40
|
+
}
|
package/dist/data.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.queryWidgetData = queryWidgetData;
|
|
4
|
+
function queryWidgetData(db) {
|
|
5
|
+
const todayStart = getTodayStartMs();
|
|
6
|
+
const monthStart = getMonthStartMs();
|
|
7
|
+
const tomorrow = todayStart + 86400000;
|
|
8
|
+
const nextMonthStart = getNextMonthStartMs();
|
|
9
|
+
const todayRow = db.prepare(`
|
|
10
|
+
SELECT
|
|
11
|
+
COALESCE(SUM(input_tokens), 0) AS input,
|
|
12
|
+
COALESCE(SUM(output_tokens), 0) AS output,
|
|
13
|
+
COALESCE(SUM(cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS other
|
|
14
|
+
FROM records
|
|
15
|
+
WHERE ts >= ? AND ts < ?
|
|
16
|
+
`).get(todayStart, tomorrow);
|
|
17
|
+
const monthRow = db.prepare(`
|
|
18
|
+
SELECT COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS total
|
|
19
|
+
FROM records
|
|
20
|
+
WHERE ts >= ? AND ts < ?
|
|
21
|
+
`).get(monthStart, nextMonthStart);
|
|
22
|
+
const modelRows = db.prepare(`
|
|
23
|
+
SELECT
|
|
24
|
+
model,
|
|
25
|
+
SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens
|
|
26
|
+
FROM records
|
|
27
|
+
WHERE ts >= ? AND ts < ?
|
|
28
|
+
GROUP BY model
|
|
29
|
+
ORDER BY tokens DESC
|
|
30
|
+
`).all(todayStart, tomorrow);
|
|
31
|
+
let topModel = null;
|
|
32
|
+
if (modelRows.length > 0) {
|
|
33
|
+
const totalTokens = modelRows.reduce((acc, r) => acc + r.tokens, 0);
|
|
34
|
+
const top = modelRows[0];
|
|
35
|
+
topModel = {
|
|
36
|
+
name: top.model,
|
|
37
|
+
share: totalTokens > 0 ? Math.round((top.tokens / totalTokens) * 100) : 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
todayTokens: {
|
|
42
|
+
total: todayRow.input + todayRow.output + todayRow.other,
|
|
43
|
+
input: todayRow.input,
|
|
44
|
+
output: todayRow.output,
|
|
45
|
+
},
|
|
46
|
+
monthTokens: {
|
|
47
|
+
total: monthRow.total,
|
|
48
|
+
},
|
|
49
|
+
topModel,
|
|
50
|
+
lastUpdated: Date.now(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function getTodayStartMs() {
|
|
54
|
+
const now = new Date();
|
|
55
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
56
|
+
}
|
|
57
|
+
function getMonthStartMs() {
|
|
58
|
+
const now = new Date();
|
|
59
|
+
return new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
60
|
+
}
|
|
61
|
+
function getNextMonthStartMs() {
|
|
62
|
+
const now = new Date();
|
|
63
|
+
return new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
|
|
64
|
+
}
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const electron_1 = require("electron");
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_os_1 = require("node:os");
|
|
7
|
+
const node_module_1 = require("node:module");
|
|
8
|
+
const data_1 = require("./data");
|
|
9
|
+
const ui_1 = require("./ui");
|
|
10
|
+
const nodeRequire = (0, node_module_1.createRequire)(__filename);
|
|
11
|
+
const Database = nodeRequire('better-sqlite3');
|
|
12
|
+
const DB_PATH = (0, node_path_1.join)((0, node_os_1.homedir)(), '.aiusage', 'cache.db');
|
|
13
|
+
const PORT_FILE = (0, node_path_1.join)((0, node_os_1.homedir)(), '.aiusage', '.serve-port');
|
|
14
|
+
const DASHBOARD_PORT = 3847;
|
|
15
|
+
const REFRESH_INTERVAL_MS = 60000;
|
|
16
|
+
let tray = null;
|
|
17
|
+
let win = null;
|
|
18
|
+
let db = null;
|
|
19
|
+
electron_1.app.setName('aiusage Widget');
|
|
20
|
+
// Prevent dock icon on macOS
|
|
21
|
+
if (process.platform === 'darwin' && electron_1.app.dock) {
|
|
22
|
+
electron_1.app.dock.hide();
|
|
23
|
+
}
|
|
24
|
+
electron_1.app.whenReady().then(() => {
|
|
25
|
+
if ((0, node_fs_1.existsSync)(DB_PATH)) {
|
|
26
|
+
db = new Database(DB_PATH, { readonly: true });
|
|
27
|
+
}
|
|
28
|
+
createTray();
|
|
29
|
+
createWindow();
|
|
30
|
+
startAutoRefresh();
|
|
31
|
+
if ((0, ui_1.shouldShowWindowOnLaunch)(electron_1.app.isPackaged)) {
|
|
32
|
+
showWindow();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
electron_1.app.on('window-all-closed', () => {
|
|
36
|
+
// Keep the app running in the tray — do not quit
|
|
37
|
+
});
|
|
38
|
+
electron_1.app.on('before-quit', () => {
|
|
39
|
+
db?.close();
|
|
40
|
+
});
|
|
41
|
+
function createTray() {
|
|
42
|
+
const icon = electron_1.nativeImage.createFromDataURL((0, ui_1.getTrayIconDataUrl)());
|
|
43
|
+
tray = new electron_1.Tray(icon);
|
|
44
|
+
tray.setToolTip('aiusage Widget');
|
|
45
|
+
// On macOS, SVG icons silently fail — use a text label as the visible entry
|
|
46
|
+
if (process.platform === 'darwin') {
|
|
47
|
+
tray.setTitle('⚡');
|
|
48
|
+
}
|
|
49
|
+
tray.on('click', () => toggleWindow());
|
|
50
|
+
tray.on('right-click', () => {
|
|
51
|
+
const menu = electron_1.Menu.buildFromTemplate([
|
|
52
|
+
{ label: 'Show Panel', click: () => showWindow() },
|
|
53
|
+
{ label: 'Refresh', click: () => pushDataUpdate() },
|
|
54
|
+
{ type: 'separator' },
|
|
55
|
+
{ label: 'Quit', click: () => { electron_1.app.exit(0); } },
|
|
56
|
+
]);
|
|
57
|
+
tray.popUpContextMenu(menu);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function createWindow() {
|
|
61
|
+
win = new electron_1.BrowserWindow({
|
|
62
|
+
width: 320,
|
|
63
|
+
height: 300,
|
|
64
|
+
show: false,
|
|
65
|
+
frame: false,
|
|
66
|
+
resizable: false,
|
|
67
|
+
skipTaskbar: true,
|
|
68
|
+
alwaysOnTop: true,
|
|
69
|
+
transparent: true,
|
|
70
|
+
webPreferences: {
|
|
71
|
+
preload: (0, node_path_1.join)(__dirname, 'preload.js'),
|
|
72
|
+
contextIsolation: true,
|
|
73
|
+
nodeIntegration: false,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const rendererPath = (0, node_path_1.join)(__dirname, 'renderer', 'index.html');
|
|
77
|
+
win.loadFile(rendererPath);
|
|
78
|
+
if ((0, ui_1.shouldHideWindowOnBlur)(electron_1.app.isPackaged)) {
|
|
79
|
+
win.on('blur', () => win?.hide());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function showWindow() {
|
|
83
|
+
if (!win)
|
|
84
|
+
return;
|
|
85
|
+
// Position near tray icon
|
|
86
|
+
const trayBounds = tray.getBounds();
|
|
87
|
+
const winBounds = win.getBounds();
|
|
88
|
+
const x = Math.round(trayBounds.x + trayBounds.width / 2 - winBounds.width / 2);
|
|
89
|
+
const y = process.platform === 'darwin'
|
|
90
|
+
? trayBounds.y + trayBounds.height + 4
|
|
91
|
+
: trayBounds.y - winBounds.height - 4;
|
|
92
|
+
win.setPosition(x, y, false);
|
|
93
|
+
win.show();
|
|
94
|
+
win.focus();
|
|
95
|
+
pushDataUpdate();
|
|
96
|
+
}
|
|
97
|
+
function toggleWindow() {
|
|
98
|
+
if (win?.isVisible()) {
|
|
99
|
+
win.hide();
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
showWindow();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function pushDataUpdate() {
|
|
106
|
+
if (!win || !db)
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
const data = (0, data_1.queryWidgetData)(db);
|
|
110
|
+
win.webContents.send('widget:data-update', data);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// DB may not be initialized yet; silently skip
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function startAutoRefresh() {
|
|
117
|
+
setInterval(() => pushDataUpdate(), REFRESH_INTERVAL_MS);
|
|
118
|
+
}
|
|
119
|
+
// IPC handlers
|
|
120
|
+
electron_1.ipcMain.handle('widget:get-data', () => {
|
|
121
|
+
if (!db)
|
|
122
|
+
return null;
|
|
123
|
+
return (0, data_1.queryWidgetData)(db);
|
|
124
|
+
});
|
|
125
|
+
electron_1.ipcMain.handle('widget:open-dashboard', async () => {
|
|
126
|
+
const port = getDashboardPort();
|
|
127
|
+
const reachable = await isDashboardReachable(port);
|
|
128
|
+
if (!reachable) {
|
|
129
|
+
const result = await launchDashboard();
|
|
130
|
+
if (!result.success) {
|
|
131
|
+
electron_1.dialog.showErrorBox('aiusage Not Found', 'The aiusage CLI is not installed.\n\nInstall it with:\n npm install -g @juliantanx/aiusage\n\nThen try again.');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
electron_1.shell.openExternal(`http://localhost:${getDashboardPort()}`);
|
|
136
|
+
});
|
|
137
|
+
electron_1.ipcMain.on('widget:hide-window', () => {
|
|
138
|
+
win?.hide();
|
|
139
|
+
});
|
|
140
|
+
function getDashboardPort() {
|
|
141
|
+
try {
|
|
142
|
+
if ((0, node_fs_1.existsSync)(PORT_FILE)) {
|
|
143
|
+
const port = parseInt((0, node_fs_1.readFileSync)(PORT_FILE, 'utf-8').trim(), 10);
|
|
144
|
+
if (!isNaN(port) && port > 0)
|
|
145
|
+
return port;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch { }
|
|
149
|
+
return DASHBOARD_PORT;
|
|
150
|
+
}
|
|
151
|
+
async function isDashboardReachable(port) {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const http = nodeRequire('http');
|
|
154
|
+
const req = http.get(`http://localhost:${port}`, (res) => {
|
|
155
|
+
res.destroy();
|
|
156
|
+
resolve(res.statusCode !== undefined && res.statusCode < 500);
|
|
157
|
+
});
|
|
158
|
+
req.on('error', () => resolve(false));
|
|
159
|
+
req.setTimeout(1000, () => { req.destroy(); resolve(false); });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
async function launchDashboard() {
|
|
163
|
+
const { spawn } = nodeRequire('child_process');
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
const child = spawn('aiusage', ['serve'], {
|
|
166
|
+
detached: true,
|
|
167
|
+
stdio: 'ignore',
|
|
168
|
+
shell: true,
|
|
169
|
+
});
|
|
170
|
+
let spawnError = null;
|
|
171
|
+
child.on('error', (err) => {
|
|
172
|
+
if (err.code === 'ENOENT') {
|
|
173
|
+
spawnError = 'aiusage command not found';
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
spawnError = err.message;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
child.unref();
|
|
180
|
+
// Wait up to 5 seconds for the server to be ready
|
|
181
|
+
let attempts = 0;
|
|
182
|
+
const check = async () => {
|
|
183
|
+
if (spawnError) {
|
|
184
|
+
resolve({ success: false, error: spawnError });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (await isDashboardReachable(getDashboardPort())) {
|
|
188
|
+
resolve({ success: true });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
attempts++;
|
|
192
|
+
if (attempts >= 10) {
|
|
193
|
+
resolve({ success: false, error: 'Server failed to start within 5 seconds' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setTimeout(check, 500);
|
|
197
|
+
};
|
|
198
|
+
check();
|
|
199
|
+
});
|
|
200
|
+
}
|
package/dist/preload.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const electron_1 = require("electron");
|
|
4
|
+
electron_1.contextBridge.exposeInMainWorld('widget', {
|
|
5
|
+
getData: () => electron_1.ipcRenderer.invoke('widget:get-data'),
|
|
6
|
+
openDashboard: () => electron_1.ipcRenderer.invoke('widget:open-dashboard'),
|
|
7
|
+
hideWindow: () => electron_1.ipcRenderer.send('widget:hide-window'),
|
|
8
|
+
onDataUpdate: (callback) => {
|
|
9
|
+
electron_1.ipcRenderer.removeAllListeners('widget:data-update');
|
|
10
|
+
electron_1.ipcRenderer.on('widget:data-update', (_event, data) => callback(data));
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.header.svelte-14gtt2i{display:flex;align-items:center;justify-content:space-between;padding:12px 14px 10px;border-bottom:1px solid var(--border);-webkit-app-region:drag}.logo.svelte-14gtt2i{font-size:13px;font-weight:600;color:var(--text-primary);letter-spacing:.01em}.actions.svelte-14gtt2i{display:flex;gap:4px;-webkit-app-region:no-drag}.icon-btn.svelte-14gtt2i{width:24px;height:24px;border:none;border-radius:4px;background:transparent;color:var(--text-muted);cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;transition:background .1s,color .1s;padding:0}.icon-btn.svelte-14gtt2i:hover{background:var(--bg-hover);color:var(--text-primary)}.section.svelte-savwyt{padding:12px 14px;border-bottom:1px solid var(--border)}.section-label.svelte-savwyt{font-size:10px;font-weight:700;letter-spacing:.08em;color:var(--text-muted);text-transform:uppercase;margin-bottom:5px}.values.svelte-savwyt{display:flex;align-items:baseline;gap:8px}.primary.svelte-savwyt{font-size:16px;font-weight:600;color:var(--text-primary);font-variant-numeric:tabular-nums}.secondary.svelte-savwyt{font-size:11px;color:var(--text-muted);font-variant-numeric:tabular-nums}*{box-sizing:border-box;margin:0;padding:0}body{background:transparent;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased}:root{--bg:#1a1a1f;--bg-hover:rgba(255, 255, 255, .06);--border:rgba(255, 255, 255, .08);--text-primary:#f0f0f2;--text-muted:rgba(240, 240, 242, .45);--accent:#6c8eff}@media (prefers-color-scheme: light){:root{--bg:#ffffff;--bg-hover:rgba(0, 0, 0, .05);--border:rgba(0, 0, 0, .08);--text-primary:#0f0f12;--text-muted:rgba(15, 15, 18, .45);--accent:#3b5bdb}}.panel.svelte-z2p4qk{background:var(--bg);border-radius:12px;border:1px solid var(--border);overflow:hidden;width:320px;box-shadow:0 8px 32px #0000004d,0 2px 8px #0003;transition:opacity .15s}.panel.loading.svelte-z2p4qk{opacity:.7}.footer.svelte-z2p4qk{padding:10px 14px 14px}.open-btn.svelte-z2p4qk{width:100%;padding:9px 14px;border:1px solid var(--accent);border-radius:7px;background:transparent;color:var(--accent);font-size:12px;font-weight:600;cursor:pointer;transition:background .15s;letter-spacing:.01em}.open-btn.svelte-z2p4qk:hover{background:#6c8eff1a}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var se=Object.defineProperty;var ie=(e,t,n)=>t in e?se(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var x=(e,t,n)=>ie(e,typeof t!="symbol"?t+"":t,n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))o(r);new MutationObserver(r=>{for(const s of r)if(s.type==="childList")for(const u of s.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&o(u)}).observe(document,{childList:!0,subtree:!0});function n(r){const s={};return r.integrity&&(s.integrity=r.integrity),r.referrerPolicy&&(s.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?s.credentials="include":r.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function o(r){if(r.ep)return;r.ep=!0;const s=n(r);fetch(r.href,s)}})();function w(){}function re(e){return e()}function X(){return Object.create(null)}function M(e){e.forEach(re)}function F(e){return typeof e=="function"}function Y(e,t){return e!=e?t==t:e!==t||e&&typeof e=="object"||typeof e=="function"}function le(e){return Object.keys(e).length===0}function d(e,t){e.appendChild(t)}function j(e,t,n){e.insertBefore(t,n||null)}function N(e){e.parentNode&&e.parentNode.removeChild(e)}function h(e){return document.createElement(e)}function I(e){return document.createTextNode(e)}function _(){return I(" ")}function K(e,t,n,o){return e.addEventListener(t,n,o),()=>e.removeEventListener(t,n,o)}function p(e,t,n){n==null?e.removeAttribute(t):e.getAttribute(t)!==n&&e.setAttribute(t,n)}function ue(e){return Array.from(e.childNodes)}function U(e,t){t=""+t,e.data!==t&&(e.data=t)}function Z(e,t,n){e.classList.toggle(t,!!n)}let T;function L(e){T=e}function ae(){if(!T)throw new Error("Function called outside component initialization");return T}function fe(e){ae().$$.on_mount.push(e)}const v=[],ee=[];let S=[];const te=[],ce=Promise.resolve();let V=!1;function de(){V||(V=!0,ce.then(oe))}function W(e){S.push(e)}const B=new Set;let $=0;function oe(){if($!==0)return;const e=T;do{try{for(;$<v.length;){const t=v[$];$++,L(t),me(t.$$)}}catch(t){throw v.length=0,$=0,t}for(L(null),v.length=0,$=0;ee.length;)ee.pop()();for(let t=0;t<S.length;t+=1){const n=S[t];B.has(n)||(B.add(n),n())}S.length=0}while(v.length);for(;te.length;)te.pop()();V=!1,B.clear(),L(e)}function me(e){if(e.fragment!==null){e.update(),M(e.before_update);const t=e.dirty;e.dirty=[-1],e.fragment&&e.fragment.p(e.ctx,t),e.after_update.forEach(W)}}function pe(e){const t=[],n=[];S.forEach(o=>e.indexOf(o)===-1?t.push(o):n.push(o)),n.forEach(o=>o()),S=t}const R=new Set;let he;function O(e,t){e&&e.i&&(R.delete(e),e.i(t))}function A(e,t,n,o){if(e&&e.o){if(R.has(e))return;R.add(e),he.c.push(()=>{R.delete(e)}),e.o(t)}}function D(e){e&&e.c()}function C(e,t,n){const{fragment:o,after_update:r}=e.$$;o&&o.m(t,n),W(()=>{const s=e.$$.on_mount.map(re).filter(F);e.$$.on_destroy?e.$$.on_destroy.push(...s):M(s),e.$$.on_mount=[]}),r.forEach(W)}function E(e,t){const n=e.$$;n.fragment!==null&&(pe(n.after_update),M(n.on_destroy),n.fragment&&n.fragment.d(t),n.on_destroy=n.fragment=null,n.ctx=[])}function ge(e,t){e.$$.dirty[0]===-1&&(v.push(e),de(),e.$$.dirty.fill(0)),e.$$.dirty[t/31|0]|=1<<t%31}function G(e,t,n,o,r,s,u=null,m=[-1]){const l=T;L(e);const i=e.$$={fragment:null,ctx:[],props:s,update:w,not_equal:r,bound:X(),on_mount:[],on_destroy:[],on_disconnect:[],before_update:[],after_update:[],context:new Map(t.context||(l?l.$$.context:[])),callbacks:X(),dirty:m,skip_bound:!1,root:t.target||l.$$.root};u&&u(i.root);let f=!1;if(i.ctx=n?n(e,t.props||{},(a,g,...b)=>{const k=b.length?b[0]:g;return i.ctx&&r(i.ctx[a],i.ctx[a]=k)&&(!i.skip_bound&&i.bound[a]&&i.bound[a](k),f&&ge(e,a)),g}):[],i.update(),f=!0,M(i.before_update),i.fragment=o?o(i.ctx):!1,t.target){if(t.hydrate){const a=ue(t.target);i.fragment&&i.fragment.l(a),a.forEach(N)}else i.fragment&&i.fragment.c();t.intro&&O(e.$$.fragment),C(e,t.target,t.anchor),oe()}L(l)}class J{constructor(){x(this,"$$");x(this,"$$set")}$destroy(){E(this,1),this.$destroy=w}$on(t,n){if(!F(n))return w;const o=this.$$.callbacks[t]||(this.$$.callbacks[t]=[]);return o.push(n),()=>{const r=o.indexOf(n);r!==-1&&o.splice(r,1)}}$set(t){this.$$set&&!le(t)&&(this.$$.skip_bound=!0,this.$$set(t),this.$$.skip_bound=!1)}}const ye="4";typeof window<"u"&&(window.__svelte||(window.__svelte={v:new Set})).v.add(ye);function _e(e){let t,n,o,r,s,u,m,l,i;return{c(){t=h("div"),n=h("span"),n.textContent="⚡ aiusage",o=_(),r=h("div"),s=h("button"),s.textContent="↻",u=_(),m=h("button"),m.textContent="✕",p(n,"class","logo svelte-14gtt2i"),p(s,"class","icon-btn svelte-14gtt2i"),p(s,"title","Refresh"),p(m,"class","icon-btn svelte-14gtt2i"),p(m,"title","Close"),p(r,"class","actions svelte-14gtt2i"),p(t,"class","header svelte-14gtt2i")},m(f,a){j(f,t,a),d(t,n),d(t,o),d(t,r),d(r,s),d(r,u),d(r,m),l||(i=[K(s,"click",function(){F(e[0])&&e[0].apply(this,arguments)}),K(m,"click",function(){F(e[1])&&e[1].apply(this,arguments)})],l=!0)},p(f,[a]){e=f},i:w,o:w,d(f){f&&N(t),l=!1,M(i)}}}function we(e,t,n){let{onRefresh:o}=t,{onClose:r}=t;return e.$$set=s=>{"onRefresh"in s&&n(0,o=s.onRefresh),"onClose"in s&&n(1,r=s.onClose)},[o,r]}class be extends J{constructor(t){super(),G(this,t,we,_e,Y,{onRefresh:0,onClose:1})}}function ne(e){let t,n;return{c(){t=h("span"),n=I(e[2]),p(t,"class","secondary svelte-savwyt")},m(o,r){j(o,t,r),d(t,n)},p(o,r){r&4&&U(n,o[2])},d(o){o&&N(t)}}}function $e(e){let t,n,o,r,s,u,m,l,i=e[2]&&ne(e);return{c(){t=h("div"),n=h("div"),o=I(e[0]),r=_(),s=h("div"),u=h("span"),m=I(e[1]),l=_(),i&&i.c(),p(n,"class","section-label svelte-savwyt"),p(u,"class","primary svelte-savwyt"),p(s,"class","values svelte-savwyt"),p(t,"class","section svelte-savwyt")},m(f,a){j(f,t,a),d(t,n),d(n,o),d(t,r),d(t,s),d(s,u),d(u,m),d(s,l),i&&i.m(s,null)},p(f,[a]){a&1&&U(o,f[0]),a&2&&U(m,f[1]),f[2]?i?i.p(f,a):(i=ne(f),i.c(),i.m(s,null)):i&&(i.d(1),i=null)},i:w,o:w,d(f){f&&N(t),i&&i.d()}}}function ve(e,t,n){let{label:o}=t,{primary:r}=t,{secondary:s=""}=t;return e.$$set=u=>{"label"in u&&n(0,o=u.label),"primary"in u&&n(1,r=u.primary),"secondary"in u&&n(2,s=u.secondary)},[o,r,s]}class H extends J{constructor(t){super(),G(this,t,ve,$e,Y,{label:0,primary:1,secondary:2})}}function Se(e){let t,n,o,r,s,u,m,l,i,f,a,g,b,k;return n=new be({props:{onRefresh:e[6],onClose:ke}}),r=new H({props:{label:"TODAY",primary:e[5],secondary:e[4]}}),u=new H({props:{label:"THIS MONTH",primary:e[3]}}),l=new H({props:{label:"TOP MODEL",primary:e[2],secondary:e[1]}}),{c(){t=h("div"),D(n.$$.fragment),o=_(),D(r.$$.fragment),s=_(),D(u.$$.fragment),m=_(),D(l.$$.fragment),i=_(),f=h("div"),a=h("button"),a.textContent="Open Full Dashboard →",p(a,"class","open-btn svelte-z2p4qk"),p(f,"class","footer svelte-z2p4qk"),p(t,"class","panel svelte-z2p4qk"),Z(t,"loading",e[0])},m(c,y){j(c,t,y),C(n,t,null),d(t,o),C(r,t,null),d(t,s),C(u,t,null),d(t,m),C(l,t,null),d(t,i),d(t,f),d(f,a),g=!0,b||(k=K(a,"click",Oe),b=!0)},p(c,[y]){const q={};y&32&&(q.primary=c[5]),y&16&&(q.secondary=c[4]),r.$set(q);const Q={};y&8&&(Q.primary=c[3]),u.$set(Q);const z={};y&4&&(z.primary=c[2]),y&2&&(z.secondary=c[1]),l.$set(z),(!g||y&1)&&Z(t,"loading",c[0])},i(c){g||(O(n.$$.fragment,c),O(r.$$.fragment,c),O(u.$$.fragment,c),O(l.$$.fragment,c),g=!0)},o(c){A(n.$$.fragment,c),A(r.$$.fragment,c),A(u.$$.fragment,c),A(l.$$.fragment,c),g=!1},d(c){c&&N(t),E(n),E(r),E(u),E(l),b=!1,k()}}}function P(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e3?`${(e/1e3).toFixed(1)}K`:String(e)}function ke(){window.widget.hideWindow()}async function Oe(){await window.widget.openDashboard()}function Ce(e,t,n){let o,r,s,u,m,l=null,i=!0;async function f(){n(0,i=!0),n(7,l=await window.widget.getData()),n(0,i=!1)}return fe(()=>{f(),window.widget.onDataUpdate(a=>{n(7,l=a),n(0,i=!1)})}),e.$$.update=()=>{var a;e.$$.dirty&128&&n(5,o=l?P(l.todayTokens.total):"—"),e.$$.dirty&128&&n(4,r=l?`↑${P(l.todayTokens.input)} ↓${P(l.todayTokens.output)}`:""),e.$$.dirty&128&&n(3,s=l?P(l.monthTokens.total):"—"),e.$$.dirty&128&&n(2,u=((a=l==null?void 0:l.topModel)==null?void 0:a.name)??"—"),e.$$.dirty&128&&n(1,m=l!=null&&l.topModel?`${l.topModel.share}%`:"")},[i,m,u,s,r,o,f,l]}class Ee extends J{constructor(t){super(),G(this,t,Ce,Se,Y,{})}}new Ee({target:document.getElementById("app")});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
|
|
7
|
+
<title>aiusage Widget</title>
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-Bb9mog0W.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="./assets/index-2X4hbwJe.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.shouldShowWindowOnLaunch = shouldShowWindowOnLaunch;
|
|
4
|
+
exports.shouldHideWindowOnBlur = shouldHideWindowOnBlur;
|
|
5
|
+
exports.shouldHideWindowOnClose = shouldHideWindowOnClose;
|
|
6
|
+
exports.getTrayIconDataUrl = getTrayIconDataUrl;
|
|
7
|
+
const TRAY_ICON_SVG = `
|
|
8
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
|
9
|
+
<rect x="1" y="1" width="14" height="14" rx="4" fill="#111827"/>
|
|
10
|
+
<path d="M8.8 2.5 4.7 8.2h2.9L7 13.5l4.3-5.9H8.4l.4-5.1Z" fill="#f8fafc"/>
|
|
11
|
+
</svg>
|
|
12
|
+
`.trim();
|
|
13
|
+
// Pre-rendered 32x32 PNG of the same icon — Windows tray doesn't support SVG
|
|
14
|
+
const TRAY_ICON_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAyUlEQVR4nMWXMQ6DMAxFzRcX6NYu' +
|
|
15
|
+
'XTtw/6N06NqFbhyhVYYsQUkT+zt+ExLI7zsCJ4gEs7RuXq6PL0NyfF5Vz+Ip7gmCWfJabcyS1xxg' +
|
|
16
|
+
'yPf3Ux0CWqlWXoIy0SyyE5HdmwIw5OoApfx23+YGYIKopVcF2MnyoQAe8sTKKFIL1/NywiJo0ftl' +
|
|
17
|
+
'rJZioYOoxchcgJAZHUqQYMAsphnJ0MpY+wEkGDCKmHfDo3Fu95JnJyQY5AvNKli7T5yk3gfUslH8e' +
|
|
18
|
+
'8BTngj/OQ3nB+9vTnl87+2CAAAAAElFTkSuQmCC';
|
|
19
|
+
function shouldShowWindowOnLaunch(isPackaged) {
|
|
20
|
+
return !isPackaged;
|
|
21
|
+
}
|
|
22
|
+
function shouldHideWindowOnBlur(isPackaged) {
|
|
23
|
+
return isPackaged;
|
|
24
|
+
}
|
|
25
|
+
function shouldHideWindowOnClose(_) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
function getTrayIconDataUrl() {
|
|
29
|
+
// Windows tray doesn't render SVG icons — use pre-rendered PNG
|
|
30
|
+
if (process.platform === 'win32') {
|
|
31
|
+
return `data:image/png;base64,${TRAY_ICON_PNG_BASE64}`;
|
|
32
|
+
}
|
|
33
|
+
return `data:image/svg+xml;base64,${Buffer.from(TRAY_ICON_SVG).toString('base64')}`;
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@juliantanx/aiusage-widget",
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "System tray widget for aiusage — view AI token usage from your system tray",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"ai",
|
|
8
|
+
"aiusage",
|
|
9
|
+
"widget",
|
|
10
|
+
"system-tray",
|
|
11
|
+
"electron",
|
|
12
|
+
"token-usage"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/juliantanx/aiusage/tree/main/packages/widget#readme",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/juliantanx/aiusage.git",
|
|
18
|
+
"directory": "packages/widget"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/juliantanx/aiusage/issues"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"main": "dist/main.js",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"bin",
|
|
31
|
+
"README.md",
|
|
32
|
+
"README_zh.md"
|
|
33
|
+
],
|
|
34
|
+
"bin": {
|
|
35
|
+
"aiusage-widget": "bin/launcher.js"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"rebuild": "pnpm run rebuild:electron",
|
|
39
|
+
"rebuild:electron": "node -e \"const {execSync}=require('child_process'); const electronVersion=require('electron/package.json').version; const cmd='npm rebuild better-sqlite3 --runtime=electron --target=' + electronVersion + ' --dist-url=https://electronjs.org/headers'; execSync(cmd, {stdio:'inherit'})\"",
|
|
40
|
+
"rebuild:node": "npm rebuild better-sqlite3",
|
|
41
|
+
"dev": "pnpm run rebuild:electron && concurrently --names \"vite,tsc,electron\" --kill-others --success command-electron \"vite build --watch\" \"tsc -p tsconfig.json --watch\" \"pnpm exec wait-on dist/main.js && pnpm exec electron .\"",
|
|
42
|
+
"build": "rm -rf dist/renderer && vite build && tsc -p tsconfig.json",
|
|
43
|
+
"pack": "pnpm build && electron-builder",
|
|
44
|
+
"test": "pnpm run rebuild:node && vitest run"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"better-sqlite3": "^11.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"electron": "^33.0.0",
|
|
51
|
+
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
|
52
|
+
"@types/better-sqlite3": "*",
|
|
53
|
+
"@types/node": "*",
|
|
54
|
+
"concurrently": "^8.0.0",
|
|
55
|
+
"electron-builder": "^25.0.0",
|
|
56
|
+
"svelte": "^4.0.0",
|
|
57
|
+
"typescript": "^5.0.0",
|
|
58
|
+
"vite": "^5.0.0",
|
|
59
|
+
"vitest": "^2.0.0",
|
|
60
|
+
"wait-on": "^7.0.0"
|
|
61
|
+
}
|
|
62
|
+
}
|