agy-mcp-bridge 0.1.13 → 0.1.15
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 +8 -0
- package/dist/src/budget.js +66 -0
- package/dist/src/tasks/manager.js +14 -4
- package/dist/src/ui/routes.js +26 -2
- package/dist/src/usage.js +129 -0
- package/package.json +1 -1
- package/ui/app.css +9 -0
- package/ui/app.js +85 -8
package/README.md
CHANGED
|
@@ -407,6 +407,14 @@ Lưu ý bảo mật: nội dung yêu cầu và phản hồi được hiển th
|
|
|
407
407
|
- **Chưa biết:** nội dung chi tiết bên trong `step_update` (chỉ biết tên các khóa ngoài cùng: `event`, `step_update`), nên phần tóm tắt chọn các trường dễ hiểu (`title`, `type`, `status`, `tool`, `message`, `path`...) nếu có, nếu không thì hiện tên các trường. Cũng chưa kiểm chứng kết quả cuối ở chế độ này có cùng dạng với chế độ JSON (`status`, `response`, `num_turns`). Nếu không giống, task báo lỗi kèm tên các trường của `result` (chỉ tên, không có giá trị) và cách quay về chế độ JSON: tắt tùy chọn này.
|
|
408
408
|
- Muốn bridge biết chính xác hình dạng bên trong các sự kiện, chạy lại `agy-mcp-bridge probe-stream` ở bản mới: nó in thêm tên và kiểu của các trường bên trong (không in giá trị).
|
|
409
409
|
|
|
410
|
+
**Theo dõi mức sử dụng (tab "Sử dụng"):** kết quả JSON của `agy` có các trường `conversation_id`, `duration_seconds`, `num_turns`, `response`, `status` và `usage`. Bridge ghi lại, cho mỗi task đã chạy xong, thời gian chạy, thời gian `agy` báo, số lượt, số bước (khi bật tiến độ) và **mọi giá trị số trong `usage`** (giữ nguyên tên trường, trường lồng nhau nối bằng dấu chấm, ví dụ `cache.read_tokens`). Tab "Sử dụng" hiện tổng task (theo trạng thái), tổng thời gian, usage cộng dồn, biểu đồ 14 ngày (theo múi giờ của trình duyệt), bảng theo model và theo mức suy luận, và các task gần nhất; chi tiết mỗi task có dòng "Usage (agy báo)". Dữ liệu nằm ở `.bridge-state/usage.jsonl` (mỗi task một dòng, giữ qua các lần khởi động lại, tự cắt bớt khi file quá 1 MB, không ghi đường dẫn hay nội dung task) và `stats` trong bản ghi task.
|
|
411
|
+
|
|
412
|
+
**Mức token (đèn cảnh báo):** Cài đặt → "Mức token" cho đặt hai mức, mặc định **không giới hạn**: mỗi task và mỗi ngày (theo giờ máy của bạn). Danh sách task hiện số token (`total_tokens` agy báo, hoặc input + output nếu không có) và đánh dấu "vượt mức" task nào quá mức; thẻ "Token hôm nay" ở trang Task đổi sang "Vượt mức" khi tổng ngày chạm mức. Đây **chỉ là đèn báo, không phải hạn mức thật và không dừng task**: `agy` không có lệnh nào cho bridge đọc hạn mức, và token của một task chỉ biết khi nó kết thúc. Mức lưu ở `.bridge-state/budget.json`, đổi không cần khởi động lại.
|
|
413
|
+
|
|
414
|
+
- **Đây là mức dùng qua bridge, không phải hạn mức.** `agy` không có lệnh xem hạn mức còn lại và bridge không bịa ra con số đó; xem hạn mức trên tài khoản Antigravity của bạn.
|
|
415
|
+
- Bridge **không biết trước** các trường bên trong `usage` (tên trường do `agy` quyết định): nó cộng mọi giá trị số; một vài tên phổ biến (`input_tokens`, `output_tokens`, `total_tokens`...) được dịch nhãn. Task nào kết quả không có `usage` thì ghi nhận là "không có", không phải 0.
|
|
416
|
+
- Chỉ thống kê task bắt đầu từ bản có tính năng này trở đi. Số liệu usage không được gửi cho Codex/Claude (không tốn token).
|
|
417
|
+
|
|
410
418
|
**Biết Antigravity còn hoạt động, và gia hạn thay vì dừng giữa chừng (time out mềm):** ở chế độ `--print --output-format json`, `agy` không in gì cho đến khi xong, nên bridge không thể biết chắc nó đang "suy luận". Bridge chỉ nhìn được những dấu hiệu còn sống: byte mới ở output, và (khi bạn bật tính năng này) việc file log của `agy` được ghi thêm. Cài đặt → "Gia hạn khi Antigravity còn hoạt động" (mặc định **tắt**, áp dụng cho task mới) làm như sau:
|
|
411
419
|
|
|
412
420
|
- Bridge yêu cầu `agy` ghi log vào `.bridge-state/agy-logs/<task_id>.log` (cờ `--log-file`) và cho `agy` giới hạn tuyệt đối làm print timeout của chính nó, thay vì hạn của task.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readUsage, totalTokens, USAGE_FILE } from './usage.js';
|
|
5
|
+
/**
|
|
6
|
+
* Token budget: two limits the owner sets in the web UI, a warning light and nothing more. agy exposes no quota to read, and the token count of
|
|
7
|
+
* a task is only known when its result arrives, so the bridge cannot stop a task midway; it can show which finished tasks went over the per-task
|
|
8
|
+
* limit and whether today's total passed the daily limit. Read on every overview, so a change needs no restart.
|
|
9
|
+
*/
|
|
10
|
+
export const BUDGET_FILE = 'budget.json';
|
|
11
|
+
export const MIN_BUDGET_TOKENS = 1000;
|
|
12
|
+
export const MAX_BUDGET_TOKENS = 1_000_000_000;
|
|
13
|
+
export const isBudgetTokens = (value) => typeof value === 'number' && Number.isInteger(value) && value >= MIN_BUDGET_TOKENS && value <= MAX_BUDGET_TOKENS;
|
|
14
|
+
const valid = (value) => isBudgetTokens(value) ? value : null;
|
|
15
|
+
/** Anything missing, unreadable or out of range means "no limit". */
|
|
16
|
+
export function readBudget(stateDirectory) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(readFileSync(path.join(stateDirectory, BUDGET_FILE), 'utf8'));
|
|
19
|
+
return { task_tokens: valid(parsed.task_tokens), day_tokens: valid(parsed.day_tokens) };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { task_tokens: null, day_tokens: null };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function writeBudget(stateDirectory, value) {
|
|
26
|
+
const file = path.join(stateDirectory, BUDGET_FILE);
|
|
27
|
+
if (value.task_tokens === null && value.day_tokens === null) {
|
|
28
|
+
await rm(file, { force: true });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const limit of [value.task_tokens, value.day_tokens])
|
|
32
|
+
if (limit !== null && !isBudgetTokens(limit))
|
|
33
|
+
throw new Error(`limits must be whole numbers from ${MIN_BUDGET_TOKENS} to ${MAX_BUDGET_TOKENS}, or null for no limit`);
|
|
34
|
+
await mkdir(stateDirectory, { recursive: true });
|
|
35
|
+
await writeFile(`${file}.tmp`, JSON.stringify(value) + '\n');
|
|
36
|
+
await rename(`${file}.tmp`, file);
|
|
37
|
+
}
|
|
38
|
+
/** Tokens used on the viewer's current local day (`tzOffsetMinutes` as `getTimezoneOffset()` returns it). */
|
|
39
|
+
export function tokensToday(records, now, tzOffsetMinutes) {
|
|
40
|
+
const offsetMs = -tzOffsetMinutes * 60000;
|
|
41
|
+
const dayOf = (ms) => new Date(ms + offsetMs).toISOString().slice(0, 10);
|
|
42
|
+
const today = dayOf(now);
|
|
43
|
+
return records.reduce((total, record) => dayOf(Date.parse(record.at)) === today ? total + (totalTokens(record.usage) ?? 0) : total, 0);
|
|
44
|
+
}
|
|
45
|
+
// The overview is polled every few seconds; the ledger is re-read only when its size or time changed.
|
|
46
|
+
let cache = null;
|
|
47
|
+
async function cachedRecords(stateDirectory) {
|
|
48
|
+
const file = path.join(stateDirectory, USAGE_FILE);
|
|
49
|
+
let key;
|
|
50
|
+
try {
|
|
51
|
+
const info = await stat(file);
|
|
52
|
+
key = `${file}:${info.size}:${info.mtimeMs}`;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
if (cache?.key !== key)
|
|
58
|
+
cache = { key, records: await readUsage(stateDirectory) };
|
|
59
|
+
return cache.records;
|
|
60
|
+
}
|
|
61
|
+
export async function budgetState(stateDirectory, tzOffsetMinutes, now = Date.now()) {
|
|
62
|
+
const budget = readBudget(stateDirectory);
|
|
63
|
+
const today = tokensToday(await cachedRecords(stateDirectory), now, tzOffsetMinutes);
|
|
64
|
+
return { ...budget, today_tokens: today, over_day: budget.day_tokens !== null && today >= budget.day_tokens };
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=budget.js.map
|
|
@@ -9,6 +9,7 @@ import { redact } from '../security/redact.js';
|
|
|
9
9
|
import { delegationState } from '../delegation.js';
|
|
10
10
|
import { startProcess, terminateOwned } from '../antigravity/process.js';
|
|
11
11
|
import { invocationFor, finalEnvelope } from '../antigravity/invocation.js';
|
|
12
|
+
import { appendUsage, extractStats, totalTokens } from '../usage.js';
|
|
12
13
|
import { MAX_RECENT_STEPS, rawStep, readProgress, summarizeStep } from '../progress.js';
|
|
13
14
|
import { helperDirectory } from '../paths.js';
|
|
14
15
|
import { readWait } from '../wait-setting.js';
|
|
@@ -321,6 +322,7 @@ export class TaskManager {
|
|
|
321
322
|
if (!entry.stop) {
|
|
322
323
|
try {
|
|
323
324
|
const result = finalEnvelope(rawStdout, transport);
|
|
325
|
+
entry.stats = extractStats(result, entry.steps?.total ?? null);
|
|
324
326
|
task.cli_status = typeof result.status === 'string' ? result.status : null;
|
|
325
327
|
task.final_response = typeof result.response === 'string' ? redact(result.response) : null;
|
|
326
328
|
if (Array.isArray(result.denied_actions))
|
|
@@ -361,6 +363,11 @@ export class TaskManager {
|
|
|
361
363
|
}
|
|
362
364
|
}
|
|
363
365
|
await this.persist(task);
|
|
366
|
+
await appendUsage(this.runtime.stateDirectory, {
|
|
367
|
+
at: task.ended_at ?? new Date().toISOString(), status: task.status, model: entry.request?.model ?? null, effort: entry.request?.effort ?? null,
|
|
368
|
+
permission: entry.request?.permission ?? null, duration_ms: task.duration_ms, agy_duration_seconds: entry.stats?.agy_duration_seconds ?? null,
|
|
369
|
+
num_turns: entry.stats?.num_turns ?? null, steps: entry.steps?.total ?? null, usage: entry.stats?.usage ?? null
|
|
370
|
+
});
|
|
364
371
|
this.busy = false;
|
|
365
372
|
entry.finish?.();
|
|
366
373
|
void this.startNext();
|
|
@@ -418,7 +425,7 @@ export class TaskManager {
|
|
|
418
425
|
/** All retained tasks, newest first, without logs. */
|
|
419
426
|
list() {
|
|
420
427
|
return [...this.entries.entries()]
|
|
421
|
-
.map(([id, entry]) => ({ ...this.summary(id), title: entry.request?.task.split('\n').find(line => line.trim())?.trim().slice(0, 140) ?? null }))
|
|
428
|
+
.map(([id, entry]) => ({ ...this.summary(id), title: entry.request?.task.split('\n').find(line => line.trim())?.trim().slice(0, 140) ?? null, tokens: totalTokens(entry.stats?.usage) }))
|
|
422
429
|
.sort((a, b) => Date.parse(b.queued_at ?? b.started_at) - Date.parse(a.queued_at ?? a.started_at));
|
|
423
430
|
}
|
|
424
431
|
/** Models offered by the installed Antigravity CLI (read on demand, never at startup). */
|
|
@@ -435,7 +442,7 @@ export class TaskManager {
|
|
|
435
442
|
...(request.context === null ? {} : { context: request.context }), timeout_seconds: request.timeout_seconds
|
|
436
443
|
}, request.client) : null;
|
|
437
444
|
const entry = this.entries.get(id);
|
|
438
|
-
return { task, request, prompt, activity: entry ? this.activityOf(entry) : null };
|
|
445
|
+
return { task, request, prompt, activity: entry ? this.activityOf(entry) : null, stats: entry?.stats ?? null };
|
|
439
446
|
}
|
|
440
447
|
/** The latest sign of life: output on stdout/stderr or a write to agy's log file (the file's modification time). 0 when there has been none. */
|
|
441
448
|
lastActivity(entry) {
|
|
@@ -555,7 +562,7 @@ export class TaskManager {
|
|
|
555
562
|
try {
|
|
556
563
|
await mkdir(this.tasksDirectory, { recursive: true, mode: 0o700 });
|
|
557
564
|
const file = path.join(this.tasksDirectory, `${task.task_id}.json`);
|
|
558
|
-
await writeFile(`${file}.tmp`, JSON.stringify({ task, request: this.entries.get(task.task_id)?.request ?? null }), { mode: 0o600 });
|
|
565
|
+
await writeFile(`${file}.tmp`, JSON.stringify({ task, request: this.entries.get(task.task_id)?.request ?? null, stats: this.entries.get(task.task_id)?.stats ?? null }), { mode: 0o600 });
|
|
559
566
|
await rename(`${file}.tmp`, file);
|
|
560
567
|
}
|
|
561
568
|
catch { /* history is a convenience, not a correctness requirement */ }
|
|
@@ -575,6 +582,7 @@ export class TaskManager {
|
|
|
575
582
|
}
|
|
576
583
|
const loaded = [];
|
|
577
584
|
const requests = new Map();
|
|
585
|
+
const statsById = new Map();
|
|
578
586
|
for (const name of names) {
|
|
579
587
|
try {
|
|
580
588
|
const parsed = JSON.parse(await readFile(path.join(this.tasksDirectory, name), 'utf8'));
|
|
@@ -585,6 +593,8 @@ export class TaskManager {
|
|
|
585
593
|
continue;
|
|
586
594
|
if (request)
|
|
587
595
|
requests.set(task.task_id, request);
|
|
596
|
+
if (parsed.task && parsed.stats)
|
|
597
|
+
statsById.set(task.task_id, parsed.stats);
|
|
588
598
|
if (task.status === 'running' || task.status === 'queued') {
|
|
589
599
|
const wasQueued = task.status === 'queued';
|
|
590
600
|
task.status = 'failed';
|
|
@@ -605,7 +615,7 @@ export class TaskManager {
|
|
|
605
615
|
for (const task of loaded.slice(this.config.max_retained_tasks))
|
|
606
616
|
await this.forget(task.task_id);
|
|
607
617
|
for (const task of keep)
|
|
608
|
-
this.entries.set(task.task_id, { task, request: requests.get(task.task_id), done: Promise.resolve() });
|
|
618
|
+
this.entries.set(task.task_id, { task, request: requests.get(task.task_id), stats: statsById.get(task.task_id), done: Promise.resolve() });
|
|
609
619
|
return keep.length;
|
|
610
620
|
}
|
|
611
621
|
// Diagnose only; never remove a lock automatically, never kill anything. PIDs can be reused, so wording stays cautious.
|
package/dist/src/ui/routes.js
CHANGED
|
@@ -8,7 +8,9 @@ import { knownUpdate, readPackageInfo } from '../update.js';
|
|
|
8
8
|
import { workspaceChanges } from '../changes.js';
|
|
9
9
|
import { validModelId, writeModel } from '../models.js';
|
|
10
10
|
import { readProgress, writeProgress } from '../progress.js';
|
|
11
|
+
import { readUsage, summarizeUsage } from '../usage.js';
|
|
11
12
|
import { EFFORT_LEVELS, isEffortLevel, readEffort, writeEffort } from '../effort.js';
|
|
13
|
+
import { MAX_BUDGET_TOKENS, MIN_BUDGET_TOKENS, budgetState, isBudgetTokens, readBudget, writeBudget } from '../budget.js';
|
|
12
14
|
import { MAX_ABSOLUTE_LIMIT_SECONDS, MIN_ABSOLUTE_LIMIT_SECONDS, isAbsoluteLimit, readSoftTimeout, writeSoftTimeout } from '../soft-timeout.js';
|
|
13
15
|
import { MAX_SETTING_WAIT_SECONDS, MIN_WAIT_SECONDS, clientToolTimeoutSeconds, isWaitSeconds, writeWait } from '../wait-setting.js';
|
|
14
16
|
import { PERMISSION_LEVELS, isPermissionLevel, needsAcknowledgement, permissionState, writePermission } from '../permissions.js';
|
|
@@ -43,7 +45,7 @@ async function readJson(req) {
|
|
|
43
45
|
throw new Error('Expected a JSON object');
|
|
44
46
|
return value;
|
|
45
47
|
}
|
|
46
|
-
async function overview(ctx) {
|
|
48
|
+
async function overview(ctx, tzOffsetMinutes = 0) {
|
|
47
49
|
const env = ctx.environment;
|
|
48
50
|
const state = delegationState(ctx.manager.stateDirectory);
|
|
49
51
|
return {
|
|
@@ -61,6 +63,7 @@ async function overview(ctx) {
|
|
|
61
63
|
permission: permissionState(ctx.manager.stateDirectory),
|
|
62
64
|
effort: readEffort(ctx.manager.stateDirectory),
|
|
63
65
|
soft_timeout: readSoftTimeout(ctx.manager.stateDirectory),
|
|
66
|
+
budget: await budgetState(ctx.manager.stateDirectory, tzOffsetMinutes),
|
|
64
67
|
progress: readProgress(ctx.manager.stateDirectory),
|
|
65
68
|
wait: { seconds: ctx.manager.maxWaitSeconds, client_tool_timeout_seconds: clientToolTimeoutSeconds(ctx.manager.maxWaitSeconds) },
|
|
66
69
|
queue: ctx.manager.overview().queue,
|
|
@@ -140,8 +143,29 @@ export async function handleUi(req, res, ctx) {
|
|
|
140
143
|
json(res, 200, { ok: true });
|
|
141
144
|
return true;
|
|
142
145
|
}
|
|
146
|
+
const tzParam = Number(new URL(req.url ?? '', 'http://localhost').searchParams.get('tz') ?? '0');
|
|
147
|
+
const tzOffset = Number.isInteger(tzParam) && Math.abs(tzParam) <= 840 ? tzParam : 0;
|
|
143
148
|
if (pathname === '/ui/api/overview' && method === 'GET') {
|
|
144
|
-
json(res, 200, await overview(ctx));
|
|
149
|
+
json(res, 200, await overview(ctx, tzOffset));
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (pathname === '/ui/api/usage' && method === 'GET') {
|
|
153
|
+
json(res, 200, summarizeUsage(await readUsage(ctx.manager.stateDirectory), { tzOffsetMinutes: tzOffset }));
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
if (pathname === '/ui/api/budget' && method === 'POST') {
|
|
157
|
+
const body = await readJson(req);
|
|
158
|
+
const current = readBudget(ctx.manager.stateDirectory);
|
|
159
|
+
const pick = (key) => body[key] === undefined ? current[key] : body[key];
|
|
160
|
+
const next = { task_tokens: pick('task_tokens'), day_tokens: pick('day_tokens') };
|
|
161
|
+
for (const limit of [next.task_tokens, next.day_tokens]) {
|
|
162
|
+
if (limit !== null && !isBudgetTokens(limit)) {
|
|
163
|
+
json(res, 400, { error: `limits must be whole numbers from ${MIN_BUDGET_TOKENS} to ${MAX_BUDGET_TOKENS}, or null for no limit` });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await writeBudget(ctx.manager.stateDirectory, next);
|
|
168
|
+
json(res, 200, await budgetState(ctx.manager.stateDirectory, tzOffset));
|
|
145
169
|
return true;
|
|
146
170
|
}
|
|
147
171
|
const detail = pathname.match(new RegExp(`^/ui/api/tasks/(${UUID})$`));
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { appendFile, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Usage of Antigravity as the bridge sees it. agy's result carries `usage`, `duration_seconds` and `num_turns`; what is inside `usage` is not
|
|
5
|
+
* assumed: every numeric value in it is kept under its own name (nested names joined with a dot) and summed. Nothing here is a quota: the
|
|
6
|
+
* bridge has no source for "remaining" and does not invent one.
|
|
7
|
+
*/
|
|
8
|
+
export const USAGE_FILE = 'usage.jsonl';
|
|
9
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
10
|
+
const KEEP_RECORDS = 3000;
|
|
11
|
+
const SAFE_KEY = /^[A-Za-z0-9_.-]{1,60}$/;
|
|
12
|
+
/** Numeric leaves of an object, at most three levels deep and 40 of them, keyed by their dotted path. */
|
|
13
|
+
export function flattenNumbers(value, prefix = '', depth = 0, out = {}) {
|
|
14
|
+
if (typeof value === 'number') {
|
|
15
|
+
if (Number.isFinite(value) && prefix && SAFE_KEY.test(prefix))
|
|
16
|
+
out[prefix] = value;
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
if (depth >= 3 || typeof value !== 'object' || value === null || Array.isArray(value))
|
|
20
|
+
return out;
|
|
21
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
22
|
+
if (Object.keys(out).length >= 40)
|
|
23
|
+
break;
|
|
24
|
+
flattenNumbers(inner, prefix ? `${prefix}.${key}` : key, depth + 1, out);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
const finiteOrNull = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
29
|
+
/** What the result of one task says about its own cost. All parts are optional: agy may leave any of them out. */
|
|
30
|
+
export function extractStats(result, steps) {
|
|
31
|
+
const usage = flattenNumbers(result.usage);
|
|
32
|
+
return { usage: Object.keys(usage).length ? usage : null, agy_duration_seconds: finiteOrNull(result.duration_seconds), num_turns: finiteOrNull(result.num_turns), steps };
|
|
33
|
+
}
|
|
34
|
+
/** One line per finished task, kept across restarts (the task history itself keeps only the latest tasks). Trimmed when the file grows large. */
|
|
35
|
+
export async function appendUsage(stateDirectory, record) {
|
|
36
|
+
const file = path.join(stateDirectory, USAGE_FILE);
|
|
37
|
+
try {
|
|
38
|
+
await appendFile(file, JSON.stringify(record) + '\n', { mode: 0o600 });
|
|
39
|
+
if ((await stat(file)).size > MAX_FILE_BYTES) {
|
|
40
|
+
const lines = (await readFile(file, 'utf8')).split('\n').filter(Boolean);
|
|
41
|
+
await writeFile(file, lines.slice(-KEEP_RECORDS).join('\n') + '\n', { mode: 0o600 });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch { /* statistics are a convenience, never a reason to fail a task */ }
|
|
45
|
+
}
|
|
46
|
+
export async function readUsage(stateDirectory) {
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
text = await readFile(path.join(stateDirectory, USAGE_FILE), 'utf8');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const records = [];
|
|
55
|
+
for (const line of text.split('\n')) {
|
|
56
|
+
if (!line.trim())
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
const value = JSON.parse(line);
|
|
60
|
+
if (typeof value.at === 'string' && Number.isFinite(Date.parse(value.at)) && typeof value.status === 'string' && typeof value.duration_ms === 'number')
|
|
61
|
+
records.push(value);
|
|
62
|
+
}
|
|
63
|
+
catch { /* skip a damaged line */ }
|
|
64
|
+
}
|
|
65
|
+
return records;
|
|
66
|
+
}
|
|
67
|
+
const add = (target, source) => {
|
|
68
|
+
for (const [key, value] of Object.entries(source ?? {}))
|
|
69
|
+
target[key] = (target[key] ?? 0) + value;
|
|
70
|
+
};
|
|
71
|
+
const sum = (values) => values.reduce((total, value) => total + (value ?? 0), 0);
|
|
72
|
+
const newGroup = () => ({ tasks: 0, completed: 0, duration_ms: 0, turns: 0, usage: {} });
|
|
73
|
+
function addTo(group, record) {
|
|
74
|
+
group.tasks += 1;
|
|
75
|
+
if (record.status === 'completed')
|
|
76
|
+
group.completed += 1;
|
|
77
|
+
group.duration_ms += record.duration_ms;
|
|
78
|
+
group.turns += record.num_turns ?? 0;
|
|
79
|
+
add(group.usage, record.usage);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Totals, a split by model and by effort, and the last `days` days. Days are the viewer's local days: `tzOffsetMinutes` is what
|
|
83
|
+
* `new Date().getTimezoneOffset()` returns in the browser (minutes behind UTC, so UTC+7 is -420).
|
|
84
|
+
*/
|
|
85
|
+
export function summarizeUsage(records, options = {}) {
|
|
86
|
+
const now = options.now ?? Date.now();
|
|
87
|
+
const offsetMs = -(options.tzOffsetMinutes ?? 0) * 60000;
|
|
88
|
+
const days = options.days ?? 14;
|
|
89
|
+
const dayOf = (ms) => new Date(ms + offsetMs).toISOString().slice(0, 10);
|
|
90
|
+
const totals = newGroup();
|
|
91
|
+
const byStatus = {};
|
|
92
|
+
const byModel = new Map();
|
|
93
|
+
const byEffort = new Map();
|
|
94
|
+
const byDay = new Map();
|
|
95
|
+
for (let i = days - 1; i >= 0; i--)
|
|
96
|
+
byDay.set(dayOf(now - i * 86400000), newGroup());
|
|
97
|
+
for (const record of records) {
|
|
98
|
+
addTo(totals, record);
|
|
99
|
+
byStatus[record.status] = (byStatus[record.status] ?? 0) + 1;
|
|
100
|
+
const model = record.model ?? 'default';
|
|
101
|
+
byModel.set(model, byModel.get(model) ?? newGroup());
|
|
102
|
+
addTo(byModel.get(model), record);
|
|
103
|
+
const effort = record.effort ?? 'default';
|
|
104
|
+
byEffort.set(effort, byEffort.get(effort) ?? newGroup());
|
|
105
|
+
addTo(byEffort.get(effort), record);
|
|
106
|
+
const day = byDay.get(dayOf(Date.parse(record.at)));
|
|
107
|
+
if (day)
|
|
108
|
+
addTo(day, record);
|
|
109
|
+
}
|
|
110
|
+
const list = (map) => [...map].map(([key, group]) => ({ key, ...group })).sort((a, b) => b.tasks - a.tasks).slice(0, 12);
|
|
111
|
+
return {
|
|
112
|
+
since: records.length ? records[0].at : null,
|
|
113
|
+
totals: { ...totals, by_status: byStatus, steps: sum(records.map(r => r.steps)), agy_duration_seconds: sum(records.map(r => r.agy_duration_seconds)),
|
|
114
|
+
with_usage: records.filter(r => r.usage).length },
|
|
115
|
+
by_model: list(byModel), by_effort: list(byEffort),
|
|
116
|
+
by_day: [...byDay].map(([day, group]) => ({ day, ...group })),
|
|
117
|
+
recent: records.slice(-15).reverse().map(r => ({ at: r.at, status: r.status, model: r.model, effort: r.effort, duration_ms: r.duration_ms, agy_duration_seconds: r.agy_duration_seconds, num_turns: r.num_turns, usage: r.usage }))
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Tokens a task cost according to agy: its own total when it reports one, else input plus output; null when it reported neither. */
|
|
121
|
+
export function totalTokens(usage) {
|
|
122
|
+
if (!usage)
|
|
123
|
+
return null;
|
|
124
|
+
if (typeof usage.total_tokens === 'number')
|
|
125
|
+
return usage.total_tokens;
|
|
126
|
+
const parts = [usage.input_tokens, usage.output_tokens].filter((value) => typeof value === 'number');
|
|
127
|
+
return parts.length ? parts.reduce((total, value) => total + value, 0) : null;
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=usage.js.map
|
package/package.json
CHANGED
package/ui/app.css
CHANGED
|
@@ -219,3 +219,12 @@ pre.log { margin: 0; padding: 12px; border-radius: 10px; background: #0b0e14; co
|
|
|
219
219
|
.steps { margin: 8px 0 0; padding-left: 0; list-style: none; display: grid; gap: 6px; }
|
|
220
220
|
.steps li { padding: 6px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
221
221
|
.steps details { margin-top: 4px; }
|
|
222
|
+
.bars { display: grid; gap: 6px; margin-top: 8px; }
|
|
223
|
+
.bar-row { display: grid; grid-template-columns: 52px 1fr 130px; gap: 10px; align-items: center; }
|
|
224
|
+
.bar-track { height: 10px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border); overflow: hidden; }
|
|
225
|
+
.bar-fill { height: 100%; background: var(--accent); }
|
|
226
|
+
.usage-table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
|
227
|
+
.usage-table th, .usage-table td { padding: 7px 8px; text-align: left; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
228
|
+
.usage-table th { font-weight: 600; color: var(--muted); }
|
|
229
|
+
|
|
230
|
+
.over-budget { color: var(--bad); font-weight: 600; }
|
package/ui/app.js
CHANGED
|
@@ -5,6 +5,7 @@ const STATUS = {queued: 'Đang chờ', running: 'Đang chạy', completed: 'Hoà
|
|
|
5
5
|
const NS = 'http://www.w3.org/2000/svg';
|
|
6
6
|
const ICONS = {
|
|
7
7
|
tasks: 'M4 6h16M4 12h16M4 18h10',
|
|
8
|
+
usage: 'M4 20V10M10 20V4M16 20v-8M22 20H2',
|
|
8
9
|
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
|
|
9
10
|
out: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9'
|
|
10
11
|
};
|
|
@@ -142,7 +143,7 @@ async function login(token) {
|
|
|
142
143
|
|
|
143
144
|
async function refresh(force) {
|
|
144
145
|
try {
|
|
145
|
-
state.overview = await api(
|
|
146
|
+
state.overview = await api(`overview?tz=${new Date().getTimezoneOffset()}`); state.connected = true;
|
|
146
147
|
const tasks = state.overview.tasks;
|
|
147
148
|
if (!state.selected || !tasks.some(task => task.task_id === state.selected)) state.selected = tasks[0]?.task_id ?? null;
|
|
148
149
|
state.detail = state.selected ? await api(`tasks/${state.selected}`).catch(() => null) : null;
|
|
@@ -240,10 +241,28 @@ async function setSoftTimeout(enabled, limit_seconds) {
|
|
|
240
241
|
await refresh();
|
|
241
242
|
} catch (error) { notify(error.message); await refresh(); }
|
|
242
243
|
}
|
|
244
|
+
const BUDGET_TASK_PRESETS = [null, 50000, 100000, 200000, 500000, 1000000];
|
|
245
|
+
const BUDGET_DAY_PRESETS = [null, 200000, 500000, 1000000, 2000000, 5000000];
|
|
246
|
+
async function setBudget(body) {
|
|
247
|
+
try { await api(`budget?tz=${new Date().getTimezoneOffset()}`, {method: 'POST', body}); notify('Đã lưu mức token'); await refresh(); }
|
|
248
|
+
catch (error) { notify(error.message); await refresh(); }
|
|
249
|
+
}
|
|
243
250
|
async function setProgress(enabled) {
|
|
244
251
|
try { await api('progress', {method: 'POST', body: {enabled}}); notify(enabled ? 'Đã bật tiến độ từng bước (áp dụng cho task mới)' : 'Đã tắt tiến độ từng bước'); await refresh(); }
|
|
245
252
|
catch (error) { notify(error.message); await refresh(); }
|
|
246
253
|
}
|
|
254
|
+
let usageAt = 0;
|
|
255
|
+
/** Usage numbers change only when a task ends, so they are fetched when the page opens and then at most every 15 seconds. */
|
|
256
|
+
async function loadUsage(force) {
|
|
257
|
+
if (!force && Date.now() - usageAt < 15000) return;
|
|
258
|
+
usageAt = Date.now();
|
|
259
|
+
try { state.usage = await api(`usage?tz=${new Date().getTimezoneOffset()}`); } catch { /* keep the numbers already shown */ }
|
|
260
|
+
patch();
|
|
261
|
+
}
|
|
262
|
+
const USAGE_LABELS = {input_tokens: 'Token vào', output_tokens: 'Token ra', total_tokens: 'Tổng token', cached_tokens: 'Token từ bộ nhớ đệm', cache_read_tokens: 'Token đọc từ bộ nhớ đệm', reasoning_tokens: 'Token suy luận', thinking_tokens: 'Token suy luận', prompt_tokens: 'Token vào', completion_tokens: 'Token ra'};
|
|
263
|
+
const usageName = key => USAGE_LABELS[key] ? `${USAGE_LABELS[key]} (${key})` : key;
|
|
264
|
+
const number = value => Number(value).toLocaleString('vi-VN', {maximumFractionDigits: 2});
|
|
265
|
+
const usageLine = usage => usage && Object.keys(usage).length ? Object.entries(usage).map(([key, value]) => `${key} ${number(value)}`).join(' · ') : null;
|
|
247
266
|
async function copy(text) {
|
|
248
267
|
try { await navigator.clipboard.writeText(text); notify('Đã sao chép'); } catch { notify('Không thể sao chép tự động'); }
|
|
249
268
|
}
|
|
@@ -281,6 +300,8 @@ function statsView(o) {
|
|
|
281
300
|
stat('Antigravity CLI', o.cli.version && o.cli.version !== 'unavailable' ? `agy ${o.cli.version}` : 'Không tìm thấy', o.cli.execution_method || 'Chưa thể giao task'),
|
|
282
301
|
stat('Giao việc', [switchControl(o.delegation.enabled, o.delegation.controllable, 'Cho phép giao việc cho Antigravity'),
|
|
283
302
|
h('span', {class: `pill ${o.delegation.enabled ? 'ok' : 'warn'}`, text: o.delegation.enabled ? 'Đang bật' : 'Đã tắt'})], o.delegation.reason || 'Codex / Claude được phép giao task'),
|
|
303
|
+
stat('Token hôm nay', [number(o.budget?.today_tokens ?? 0), o.budget?.day_tokens ? h('span', {class: `pill ${o.budget.over_day ? 'bad' : 'ok'}`, text: o.budget.over_day ? 'Vượt mức' : 'Trong mức'}) : null],
|
|
304
|
+
o.budget?.day_tokens ? `mức ngày ${number(o.budget.day_tokens)}${o.budget.over_day ? ' — đã vượt' : ''}` : 'chưa đặt mức ngày (Cài đặt)'),
|
|
284
305
|
stat('Task', `${count('running')} đang chạy`, `${queued ? `${queued} đang chờ · ` : ''}${o.tasks.length} task gần đây`)
|
|
285
306
|
];
|
|
286
307
|
}
|
|
@@ -289,6 +310,7 @@ function filteredTasks(o) {
|
|
|
289
310
|
const q = state.q.trim().toLowerCase();
|
|
290
311
|
return o.tasks.filter(task => MATCH[state.filter](task) && (!q || [task.title, task.workspace, task.task_id].some(v => (v || '').toLowerCase().includes(q))));
|
|
291
312
|
}
|
|
313
|
+
const overTask = (o, task) => Boolean(o.budget?.task_tokens) && task.tokens !== null && task.tokens !== undefined && task.tokens > o.budget.task_tokens;
|
|
292
314
|
function taskItems(o) {
|
|
293
315
|
if (!o.tasks.length) return [h('div', {class: 'card empty'}, h('strong', {text: 'Chưa có task nào'}), 'Khi Codex hoặc Claude giao việc cho Antigravity, task sẽ hiện ở đây.')];
|
|
294
316
|
const tasks = filteredTasks(o);
|
|
@@ -298,7 +320,8 @@ function taskItems(o) {
|
|
|
298
320
|
}, h('div', {class: 'row'}, pill(task.status), agoEl(task.queued_at || task.started_at, 'meta')),
|
|
299
321
|
h('div', {class: 'title', text: task.title || '(không có nội dung yêu cầu)'}),
|
|
300
322
|
h('div', {class: 'meta mono', text: `${folder(task.workspace)} · ${task.task_id.slice(0, 8)}`}),
|
|
301
|
-
h('div', {class: 'meta'}, task.status === 'running' ? elapsedEl(task.started_at, 'Đang chạy ') : task.status === 'queued' ? 'Đang xếp hàng…' : `Mất ${duration(task.duration_ms)}
|
|
323
|
+
h('div', {class: 'meta'}, task.status === 'running' ? elapsedEl(task.started_at, 'Đang chạy ') : task.status === 'queued' ? 'Đang xếp hàng…' : `Mất ${duration(task.duration_ms)}`,
|
|
324
|
+
task.tokens !== null && task.tokens !== undefined ? h('span', {class: overTask(o, task) ? 'over-budget' : '', text: ` · ${number(task.tokens)} token${overTask(o, task) ? ' (vượt mức)' : ''}`}) : null)));
|
|
302
325
|
}
|
|
303
326
|
function taskCount(o) { return `${filteredTasks(o).length}/${o.tasks.length} task`; }
|
|
304
327
|
|
|
@@ -362,7 +385,7 @@ function detailView() {
|
|
|
362
385
|
const facts = h('dl', {class: 'kv'},
|
|
363
386
|
...[['Gửi lúc', clock(task.queued_at || task.started_at)], ['Bắt đầu chạy', queued ? 'Chưa chạy' : clock(task.started_at)], ['Kết thúc', clock(task.ended_at)],
|
|
364
387
|
['Thời gian chạy', running ? elapsedEl(task.started_at) : queued ? '—' : duration(task.duration_ms)],
|
|
365
|
-
['Giới hạn', request ? `${request.timeout_seconds}s` : '—'], ['Model', request && 'model' in request ? request.model || 'mặc định' : '—'], ['Quyền', request && request.permission ? (PERMISSION_LEVELS.find(l => l.id === request.permission)?.title || request.permission) : '—'], ['Effort', request && request.effort ? request.effort : '—'], ['Exit code', task.exit_code ?? '—'], ['PID', task.pid ?? '—'],
|
|
388
|
+
['Giới hạn', request ? `${request.timeout_seconds}s` : '—'], ['Model', request && 'model' in request ? request.model || 'mặc định' : '—'], ['Quyền', request && request.permission ? (PERMISSION_LEVELS.find(l => l.id === request.permission)?.title || request.permission) : '—'], ['Effort', request && request.effort ? request.effort : '—'], ['Tổng token', state.detail?.stats?.usage && 'total_tokens' in state.detail.stats.usage ? number(state.detail.stats.usage.total_tokens) : '—'], ['Usage (agy báo)', usageLine(state.detail?.stats?.usage) ?? '—'], ['Exit code', task.exit_code ?? '—'], ['PID', task.pid ?? '—'],
|
|
366
389
|
['CLI status', task.cli_status ?? '—']].map(([k, v]) => h('div', {}, h('dt', {text: k}), h('dd', {}, typeof v === 'string' || typeof v === 'number' ? String(v) : v))));
|
|
367
390
|
|
|
368
391
|
const asker = request?.client ? `Tech Lead · ${request.client}` : 'Tech Lead (Codex / Claude)';
|
|
@@ -439,6 +462,20 @@ function progressCard(o) {
|
|
|
439
462
|
h('span', {class: 'muted', text: 'Bridge chạy agy với --output-format stream-json: ngoài kết quả cuối, agy báo các sự kiện init và step_update khi đang làm việc (đã thấy bằng probe-stream trên agy 1.4.x). Các bước hiện trong chi tiết task, và là bằng chứng "còn sống" thật cho phần gia hạn. Áp dụng cho task mới, không cần khởi động lại.'}))),
|
|
440
463
|
h('div', {class: 'callout warn', text: 'Chưa kiểm chứng: nội dung chi tiết bên trong step_update (nên hiển thị là tóm tắt tự động kèm dữ liệu gốc), và việc kết quả cuối ở chế độ này có cùng dạng với chế độ JSON. Nếu task báo lỗi "CLI output is not a valid stream-json final result" hoặc "stream result fields", hãy tắt tùy chọn này để quay về chế độ JSON đã kiểm chứng và gửi tôi thông báo lỗi.'}));
|
|
441
464
|
}
|
|
465
|
+
function budgetCard(o) {
|
|
466
|
+
const budget = o.budget || {task_tokens: null, day_tokens: null, today_tokens: 0};
|
|
467
|
+
const chooser = (key, presets, label) => {
|
|
468
|
+
const options = presets.includes(budget[key]) ? presets : [...presets, budget[key]];
|
|
469
|
+
const select = h('select', {class: 'select', 'aria-label': label, onchange: event => setBudget({[key]: event.target.value === '' ? null : Number(event.target.value)})},
|
|
470
|
+
...options.map(value => h('option', {value: value === null ? '' : String(value), text: value === null ? 'Không giới hạn' : `${number(value)} token`})));
|
|
471
|
+
select.value = budget[key] === null ? '' : String(budget[key]);
|
|
472
|
+
return select;
|
|
473
|
+
};
|
|
474
|
+
return h('section', {class: 'card panel wide'}, h('h2', {text: 'Mức token (đèn cảnh báo)'}),
|
|
475
|
+
h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: 'Mỗi task'}), h('span', {class: 'muted', text: 'Task nào dùng nhiều hơn mức này sẽ được đánh dấu "vượt mức" trong danh sách.'})), chooser('task_tokens', BUDGET_TASK_PRESETS, 'Mức token mỗi task')),
|
|
476
|
+
h('div', {class: 'switch-row'}, h('div', {}, h('strong', {text: 'Mỗi ngày'}), h('span', {class: 'muted', text: `Hôm nay đã dùng ${number(budget.today_tokens)} token qua bridge${budget.over_day ? ' — đã vượt mức.' : '.'}`})), chooser('day_tokens', BUDGET_DAY_PRESETS, 'Mức token mỗi ngày')),
|
|
477
|
+
h('div', {class: 'callout warn', text: 'Đây chỉ là đèn báo, không phải hạn mức thật và không dừng task: agy không cho bridge đọc hạn mức, và số token của một task chỉ biết khi task kết thúc. Số liệu là tổng token (total_tokens) agy báo cho các task chạy qua bridge, tính theo ngày giờ máy của bạn.'}));
|
|
478
|
+
}
|
|
442
479
|
function softTimeoutCard(o) {
|
|
443
480
|
const soft = o.soft_timeout || {enabled: false, limit_seconds: 7200};
|
|
444
481
|
const known = SOFT_LIMITS.some(l => l.seconds === soft.limit_seconds);
|
|
@@ -488,6 +525,39 @@ function permissionCard(o) {
|
|
|
488
525
|
p.controllable ? null : h('div', {class: 'callout warn', text: `Đang bị khóa bởi biến môi trường BRIDGE_AGY_PERMISSIONS${p.reason && p.reason !== 'BRIDGE_AGY_PERMISSIONS' ? ` (${p.reason})` : ''}. Đổi biến rồi khởi động lại server để mở.`}),
|
|
489
526
|
h('div', {class: 'callout warn', text: 'Danh sách "Workspace được phép" chỉ kiểm tra đường dẫn của task, không phải sandbox. Với mức Tự động hoặc Toàn quyền, Antigravity có thể tác động ra ngoài thư mục đó.'}));
|
|
490
527
|
}
|
|
528
|
+
function usageView(u) {
|
|
529
|
+
const head = h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Sử dụng'}), h('p', {text: 'Số liệu bridge ghi lại từ các task đã chạy qua nó.'})),
|
|
530
|
+
h('button', {class: 'btn small', onclick: () => loadUsage(true), text: 'Làm mới'}));
|
|
531
|
+
if (!u) return [head, h('div', {class: 'card empty', text: 'Đang tải…'})];
|
|
532
|
+
const t = u.totals;
|
|
533
|
+
const status = Object.entries(t.by_status).map(([key, count]) => `${STATUS[key] || key} ${count}`).join(' · ');
|
|
534
|
+
const cards = h('section', {class: 'stats', 'aria-label': 'Tổng quan sử dụng'},
|
|
535
|
+
stat('Task đã chạy', number(t.tasks), t.tasks ? `${number(t.completed)} hoàn tất · ${status}` : 'chưa có task nào'),
|
|
536
|
+
stat('Thời gian chạy', duration(t.duration_ms), 'tổng, do bridge đo'),
|
|
537
|
+
stat('Thời gian agy báo', t.agy_duration_seconds ? duration(t.agy_duration_seconds * 1000) : '—', 'tổng duration_seconds của agy'),
|
|
538
|
+
stat('Số lượt / bước', `${number(t.turns)} / ${number(t.steps)}`, 'num_turns / bước báo cáo'));
|
|
539
|
+
const callout = h('div', {class: 'callout warn', text: `Đây là mức dùng qua bridge${u.since ? ` kể từ ${clock(u.since)}` : ''} (nhật ký lưu qua các lần khởi động lại), không phải hạn mức. Hạn mức còn lại không có nguồn từ agy nên bridge không hiển thị; xem trên tài khoản Antigravity của bạn.`});
|
|
540
|
+
const sums = Object.entries(t.usage);
|
|
541
|
+
const usageCard = h('section', {class: 'card panel wide'}, h('h2', {text: 'Usage do agy báo'}),
|
|
542
|
+
sums.length ? [h('dl', {class: 'rows'}, sums.map(([key, value]) => row(usageName(key), number(value)))),
|
|
543
|
+
h('p', {class: 'muted', text: `Cộng dồn từ ${number(t.with_usage)} task có trường usage trong kết quả của agy. Tên trường do agy quyết định; bridge cộng mọi giá trị số.`})]
|
|
544
|
+
: h('p', {class: 'muted', text: 'Chưa có task nào mà kết quả của agy kèm số liệu usage. Với task chạy ở chế độ JSON hoặc stream, nếu agy trả trường usage thì tổng sẽ hiện ở đây.'}));
|
|
545
|
+
const peak = Math.max(1, ...u.by_day.map(d => d.tasks));
|
|
546
|
+
const bars = h('div', {class: 'bars'}, u.by_day.map(d => {
|
|
547
|
+
const fill = h('div', {class: 'bar-fill'}); fill.style.width = `${(d.tasks / peak * 100).toFixed(1)}%`;
|
|
548
|
+
return h('div', {class: 'bar-row'}, h('span', {class: 'muted mono', text: `${d.day.slice(8)}/${d.day.slice(5, 7)}`}), h('div', {class: 'bar-track'}, fill),
|
|
549
|
+
h('span', {class: 'mono', text: d.tasks ? `${d.tasks} · ${duration(d.duration_ms)}` : '0'}));
|
|
550
|
+
}));
|
|
551
|
+
const dayCard = h('section', {class: 'card panel wide'}, h('h2', {text: '14 ngày gần nhất'}), bars);
|
|
552
|
+
const groupTable = (title, groups, firstColumn) => h('section', {class: 'card panel wide'}, h('h2', {text: title}),
|
|
553
|
+
groups.length ? h('table', {class: 'usage-table'}, h('thead', {}, h('tr', {}, ...[firstColumn, 'Task', 'Hoàn tất', 'Thời gian', 'Usage'].map(c => h('th', {text: c})))),
|
|
554
|
+
h('tbody', {}, groups.map(g => h('tr', {}, h('td', {class: 'mono', text: g.key}), h('td', {text: number(g.tasks)}), h('td', {text: number(g.completed)}), h('td', {text: duration(g.duration_ms)}), h('td', {class: 'muted', text: usageLine(g.usage) ?? '—'})))))
|
|
555
|
+
: h('p', {class: 'muted', text: 'Chưa có dữ liệu.'}));
|
|
556
|
+
const recent = h('section', {class: 'card panel wide'}, h('h2', {text: 'Task gần nhất'}),
|
|
557
|
+
u.recent.length ? h('ul', {class: 'path-list'}, u.recent.map(r => h('li', {}, `${clock(r.at)} · ${STATUS[r.status] || r.status} · ${r.model || 'model mặc định'} · effort ${r.effort || 'mặc định'} · ${duration(r.duration_ms)}`,
|
|
558
|
+
usageLine(r.usage) ? h('span', {class: 'tag', text: usageLine(r.usage)}) : null))) : h('p', {class: 'muted', text: 'Chưa có task nào.'}));
|
|
559
|
+
return [head, callout, cards, usageCard, dayCard, groupTable('Theo model', u.by_model, 'Model'), groupTable('Theo mức suy luận', u.by_effort, 'Effort'), recent];
|
|
560
|
+
}
|
|
491
561
|
function settingsView(o) {
|
|
492
562
|
const delegation = o.delegation;
|
|
493
563
|
return [h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Cài đặt'}), h('p', {text: 'Trạng thái và giới hạn của bridge. Phần lớn chỉ đọc; chỉnh bằng file cấu hình rồi khởi động lại.'}))),
|
|
@@ -502,6 +572,7 @@ function settingsView(o) {
|
|
|
502
572
|
effortCard(o),
|
|
503
573
|
waitCard(o),
|
|
504
574
|
progressCard(o),
|
|
575
|
+
budgetCard(o),
|
|
505
576
|
softTimeoutCard(o),
|
|
506
577
|
permissionCard(o),
|
|
507
578
|
h('section', {class: 'card panel'}, h('h2', {text: 'Kết nối'}),
|
|
@@ -527,10 +598,10 @@ function settingsView(o) {
|
|
|
527
598
|
|
|
528
599
|
/* ---------- Shell: built once per view change, then patched region by region ---------- */
|
|
529
600
|
function mount() {
|
|
530
|
-
const nav = (view, label) => h('button', {'aria-current': state.view === view ? 'page' : null, onclick: () => { state.view = view; render(); }}, icon(view === 'tasks' ? 'tasks' : 'settings'), label);
|
|
601
|
+
const nav = (view, label) => h('button', {'aria-current': state.view === view ? 'page' : null, onclick: () => { state.view = view; render(); }}, icon(view === 'tasks' ? 'tasks' : view === 'usage' ? 'usage' : 'settings'), label);
|
|
531
602
|
const live = h('div', {class: 'live', role: 'status'});
|
|
532
603
|
const side = h('aside', {class: 'side'}, h('div', {class: 'brand'}, h('img', {src: '/ui/favicon.svg', alt: ''}), h('span', {text: 'Antigravity Bridge'})),
|
|
533
|
-
h('nav', {class: 'nav', 'aria-label': 'Điều hướng'}, nav('tasks', 'Task'), nav('settings', 'Cài đặt')),
|
|
604
|
+
h('nav', {class: 'nav', 'aria-label': 'Điều hướng'}, nav('tasks', 'Task'), nav('usage', 'Sử dụng'), nav('settings', 'Cài đặt')),
|
|
534
605
|
h('div', {class: 'side-foot'}, live));
|
|
535
606
|
const banner = h('div', {'aria-live': 'polite'});
|
|
536
607
|
const main = h('main', {class: 'main'}, banner);
|
|
@@ -546,6 +617,9 @@ function mount() {
|
|
|
546
617
|
class: 'chip-btn', 'data-filter': key, 'aria-pressed': String(state.filter === key), onclick: () => { state.filter = key; for (const b of chips.children) b.setAttribute('aria-pressed', String(b.dataset.filter === key)); patch(); }, text: label})));
|
|
547
618
|
main.append(h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Task'}), h('p', {text: 'Nội dung trao đổi giữa Tech Lead và Antigravity.'}))),
|
|
548
619
|
regions.stats, h('div', {class: 'tasks'}, h('div', {class: 'list-col'}, h('div', {class: 'list-tools'}, search, chips, regions.count), regions.list), regions.detail));
|
|
620
|
+
} else if (state.view === 'usage') {
|
|
621
|
+
regions.usage = h('div', {});
|
|
622
|
+
main.append(regions.usage);
|
|
549
623
|
} else {
|
|
550
624
|
regions.settings = h('div', {});
|
|
551
625
|
main.append(regions.settings);
|
|
@@ -573,8 +647,8 @@ function patch() {
|
|
|
573
647
|
o.permission && (o.permission.level === 'full' || o.permission.level === 'auto') ? h('div', {class: 'banner warn', role: 'alert', text: o.permission.level === 'full' ? 'Antigravity đang ở mức TOÀN QUYỀN: mọi thao tác được tự duyệt, kể cả ngoài thư mục dự án. Đổi lại ở Cài đặt.' : 'Antigravity đang ở chế độ Tự động: nó tự quyết định hành động. Đổi lại ở Cài đặt.'}) : null,
|
|
574
648
|
o.update ? h('div', {class: 'banner info', role: 'status'}, `Có bản mới ${o.update.package} ${o.update.latest} (đang chạy ${o.update.current}). Dừng server (Ctrl+C) rồi chạy `, h('code', {text: o.update.command})) : null].filter(Boolean));
|
|
575
649
|
if (regions.view === 'tasks') {
|
|
576
|
-
setRegion('stats', JSON.stringify([o.tasks.map(t => [t.task_id, t.status]), o.cli, o.delegation, o.server.accepting_tasks]), () => statsView(o));
|
|
577
|
-
setRegion('list', JSON.stringify([state.q, state.filter, state.selected, o.tasks.map(t => stable(t))]), () => taskItems(o));
|
|
650
|
+
setRegion('stats', JSON.stringify([o.tasks.map(t => [t.task_id, t.status]), o.budget, o.cli, o.delegation, o.server.accepting_tasks]), () => statsView(o));
|
|
651
|
+
setRegion('list', JSON.stringify([state.q, state.filter, state.selected, o.budget?.task_tokens, o.tasks.map(t => stable(t))]), () => taskItems(o));
|
|
578
652
|
regions.count.textContent = taskCount(o);
|
|
579
653
|
const d = state.detail;
|
|
580
654
|
const detailKey = JSON.stringify([state.selected, state.logTab, state.changes, d && {...d, activity: null, task: {...stable(d.task), stdout: '', stderr: '', stdout_bytes: 0, stderr_bytes: 0, stdout_truncated: false, stderr_truncated: false}}]);
|
|
@@ -583,9 +657,12 @@ function patch() {
|
|
|
583
657
|
if (!rebuilt) patchLogs();
|
|
584
658
|
patchActivity();
|
|
585
659
|
patchSteps();
|
|
660
|
+
} else if (regions.view === 'usage') {
|
|
661
|
+
loadUsage();
|
|
662
|
+
setRegion('usage', JSON.stringify(state.usage), () => usageView(state.usage));
|
|
586
663
|
} else {
|
|
587
664
|
if (!state.models && !state.modelsLoading) loadModels();
|
|
588
|
-
setRegion('settings', JSON.stringify([state.models, o.model, o.permission, o.effort, o.wait, o.soft_timeout, o.progress, o.cli, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));
|
|
665
|
+
setRegion('settings', JSON.stringify([state.models, o.model, o.permission, o.effort, o.wait, o.budget && [o.budget.task_tokens, o.budget.day_tokens], o.soft_timeout, o.progress, o.cli, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));
|
|
589
666
|
}
|
|
590
667
|
tick();
|
|
591
668
|
}
|