mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Add share widget defaults and PNG export support
This commit is contained in:
parent
437cf29d3e
commit
83ea5d8b5b
13 changed files with 1385 additions and 42 deletions
|
|
@ -702,7 +702,7 @@ function parseOverviewWidget(value: unknown): OverviewWidgetConfig | undefined {
|
|||
}
|
||||
|
||||
function parseOverviewWidgetType(value: unknown): OverviewWidgetType | undefined {
|
||||
return parseEnumValue(value, ["account-balance", "client-analysis", "metric", "model-distribution", "provider-analysis", "system-status", "token-activity", "token-mix", "usage-trend"], undefined);
|
||||
return parseEnumValue(value, ["account-balance", "client-analysis", "metric", "model-distribution", "provider-analysis", "share-fuel-cockpit", "share-model-leaderboard", "share-route-map", "share-spend-receipt", "share-token-calendar", "share-usage-wrapped", "system-status", "token-activity", "token-mix", "usage-trend"], undefined);
|
||||
}
|
||||
|
||||
function parseOverviewWidgetSize(value: unknown, type: OverviewWidgetType): OverviewWidgetSize | undefined {
|
||||
|
|
@ -755,6 +755,9 @@ function defaultOverviewWidgetSize(type: OverviewWidgetType): OverviewWidgetSize
|
|||
if (type === "system-status") {
|
||||
return "4:1";
|
||||
}
|
||||
if (isShareOverviewWidgetType(type)) {
|
||||
return "1:4";
|
||||
}
|
||||
return "4:2";
|
||||
}
|
||||
|
||||
|
|
@ -780,6 +783,9 @@ function defaultOverviewWidgetVariant(type: OverviewWidgetType): OverviewWidgetV
|
|||
if (type === "system-status") {
|
||||
return "timeline";
|
||||
}
|
||||
if (isShareOverviewWidgetType(type)) {
|
||||
return "card";
|
||||
}
|
||||
return "table";
|
||||
}
|
||||
|
||||
|
|
@ -787,6 +793,15 @@ function overviewWidgetId(type: OverviewWidgetType, metric?: OverviewMetricKind)
|
|||
return type === "metric" ? `metric-${metric ?? "requests"}` : type;
|
||||
}
|
||||
|
||||
function isShareOverviewWidgetType(type: OverviewWidgetType): boolean {
|
||||
return type === "share-fuel-cockpit" ||
|
||||
type === "share-model-leaderboard" ||
|
||||
type === "share-route-map" ||
|
||||
type === "share-spend-receipt" ||
|
||||
type === "share-token-calendar" ||
|
||||
type === "share-usage-wrapped";
|
||||
}
|
||||
|
||||
function parseTrayIconPreference(value: unknown): TrayIconPreference | undefined {
|
||||
if (value === "random" || value === "violet" || value === "orange" || value === "cyan" || value === "progress") {
|
||||
return value;
|
||||
|
|
|
|||
365
src/main/ipc.ts
365
src/main/ipc.ts
|
|
@ -1,7 +1,9 @@
|
|||
import { app, BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions, type SaveDialogOptions } from "electron";
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { deflateSync, inflateSync } from "node:zlib";
|
||||
import { loadPersistedAppSetting, replacePersistedAppSetting } from "./app-config-store";
|
||||
import { builtInBrowserService } from "./built-in-browser";
|
||||
import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "./bot-handoff-scan-service";
|
||||
|
|
@ -29,7 +31,7 @@ import trayController from "./tray-controller";
|
|||
import { appUpdateService } from "./update-service";
|
||||
import { getUsageStats } from "./usage-store";
|
||||
import windowsManager from "./windows";
|
||||
import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppConfig, AppDataExportResult, AppInfo, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
|
||||
import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
|
||||
|
||||
const pluginMarketplace: PluginMarketplaceEntry[] = [
|
||||
{
|
||||
|
|
@ -50,6 +52,7 @@ const pluginMarketplace: PluginMarketplaceEntry[] = [
|
|||
}
|
||||
];
|
||||
const onboardingFinishedAtSettingKey = "onboardingFinishedAt";
|
||||
const imageExportTargets = new Map<string, string>();
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
|
||||
return {
|
||||
|
|
@ -70,6 +73,12 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
|
|||
ipcMain.handle(IPC_CHANNELS.appExportData, async (event): Promise<AppDataExportResult> => {
|
||||
return exportAppData(BrowserWindow.fromWebContents(event.sender));
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appCaptureElementPng, async (event, request: AppCaptureElementPngRequest): Promise<AppCaptureElementPngResult> => {
|
||||
return captureElementPng(BrowserWindow.fromWebContents(event.sender), request);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appPrepareImageExportTarget, async (event, request: AppImageExportTargetRequest): Promise<AppImageExportTargetResult> => {
|
||||
return prepareImageExportTarget(BrowserWindow.fromWebContents(event.sender), request);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, async () => {
|
||||
|
|
@ -539,6 +548,43 @@ async function exportAppData(window: BrowserWindow | null): Promise<AppDataExpor
|
|||
};
|
||||
}
|
||||
|
||||
async function captureElementPng(window: BrowserWindow | null, request: AppCaptureElementPngRequest): Promise<AppCaptureElementPngResult> {
|
||||
if (!window) {
|
||||
throw new Error("Window is unavailable.");
|
||||
}
|
||||
|
||||
const rect = sanitizeCaptureRect(request.rect);
|
||||
const targetFile = request.exportId ? consumeImageExportTarget(request.exportId) : undefined;
|
||||
const result = targetFile
|
||||
? { canceled: false, filePath: targetFile }
|
||||
: await dialog.showSaveDialog(window, shareCardSaveDialogOptions(request.fileName));
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { canceled: true };
|
||||
}
|
||||
|
||||
const image = await window.webContents.capturePage(rect);
|
||||
const png = image.toPNG();
|
||||
writeFileSync(result.filePath, pngWithExportProcessing(png, request, rect.width), { mode: 0o600 });
|
||||
return { canceled: false, file: result.filePath };
|
||||
}
|
||||
|
||||
async function prepareImageExportTarget(window: BrowserWindow | null, request: AppImageExportTargetRequest): Promise<AppImageExportTargetResult> {
|
||||
const result = window
|
||||
? await dialog.showSaveDialog(window, shareCardSaveDialogOptions(request.fileName))
|
||||
: await dialog.showSaveDialog(shareCardSaveDialogOptions(request.fileName));
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { canceled: true };
|
||||
}
|
||||
|
||||
const exportId = randomUUID();
|
||||
imageExportTargets.set(exportId, result.filePath);
|
||||
return {
|
||||
canceled: false,
|
||||
exportId,
|
||||
file: result.filePath
|
||||
};
|
||||
}
|
||||
|
||||
function dataExportSaveDialogOptions(exportedAt: string): SaveDialogOptions {
|
||||
return {
|
||||
buttonLabel: "Export",
|
||||
|
|
@ -550,6 +596,321 @@ function dataExportSaveDialogOptions(exportedAt: string): SaveDialogOptions {
|
|||
};
|
||||
}
|
||||
|
||||
function shareCardSaveDialogOptions(fileName: string): SaveDialogOptions {
|
||||
return {
|
||||
buttonLabel: "Save image",
|
||||
defaultPath: path.join(app.getPath("downloads"), safePngFileName(fileName)),
|
||||
filters: [
|
||||
{ extensions: ["png"], name: "PNG image" }
|
||||
],
|
||||
title: "Save image"
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeCaptureRect(rect: AppCaptureElementPngRequest["rect"]): Rectangle {
|
||||
const x = finiteNumber(rect?.x, "capture x");
|
||||
const y = finiteNumber(rect?.y, "capture y");
|
||||
const width = finiteNumber(rect?.width, "capture width");
|
||||
const height = finiteNumber(rect?.height, "capture height");
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error("Capture area must not be empty.");
|
||||
}
|
||||
return {
|
||||
height: Math.ceil(height),
|
||||
width: Math.ceil(width),
|
||||
x: Math.max(0, Math.floor(x)),
|
||||
y: Math.max(0, Math.floor(y))
|
||||
};
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error(`Invalid ${label}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safePngFileName(value: string): string {
|
||||
const raw = typeof value === "string" ? value : "";
|
||||
const safe = path.basename(raw).replace(/[<>:"/\\|?*\x00-\x1f]/g, "-").trim() || "ccr-share-card.png";
|
||||
return safe.toLowerCase().endsWith(".png") ? safe : `${safe}.png`;
|
||||
}
|
||||
|
||||
function consumeImageExportTarget(exportId: string): string {
|
||||
const target = imageExportTargets.get(exportId);
|
||||
imageExportTargets.delete(exportId);
|
||||
if (!target) {
|
||||
throw new Error("Image export target is unavailable.");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
type DecodedPngPixels = {
|
||||
bitDepth: number;
|
||||
colorType: 2 | 6;
|
||||
height: number;
|
||||
pixels: Uint8Array;
|
||||
width: number;
|
||||
};
|
||||
|
||||
function pngWithExportProcessing(png: Buffer, request: AppCaptureElementPngRequest, cssWidth: number): Buffer {
|
||||
const radius = typeof request.borderRadius === "number" && Number.isFinite(request.borderRadius) ? Math.max(0, request.borderRadius) : 0;
|
||||
const outputWidth = sanitizePngOutputDimension(request.output?.width);
|
||||
const outputHeight = sanitizePngOutputDimension(request.output?.height);
|
||||
if (radius <= 0 && (!outputWidth || !outputHeight)) {
|
||||
return png;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = decodePngPixels(png);
|
||||
let width = decoded.width;
|
||||
let height = decoded.height;
|
||||
let rgba = pngPixelsToRgba(decoded);
|
||||
if (outputWidth && outputHeight && (outputWidth !== width || outputHeight !== height)) {
|
||||
rgba = resizeRgbaBilinear(rgba, width, height, outputWidth, outputHeight);
|
||||
width = outputWidth;
|
||||
height = outputHeight;
|
||||
}
|
||||
if (radius > 0 && cssWidth > 0) {
|
||||
const pixelRadius = Math.min(width / 2, height / 2, radius * width / cssWidth);
|
||||
applyRoundedAlphaMask(rgba, width, height, pixelRadius);
|
||||
}
|
||||
return encodeRgbaPng(width, height, rgba);
|
||||
} catch (error) {
|
||||
console.warn(`[export] Failed to process exported PNG: ${formatError(error)}`);
|
||||
return png;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePngOutputDimension(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
return rounded > 0 && rounded <= 4096 ? rounded : undefined;
|
||||
}
|
||||
|
||||
function decodePngPixels(png: Buffer): DecodedPngPixels {
|
||||
if (png.length < 33 || !png.subarray(0, pngSignature.length).equals(pngSignature)) {
|
||||
throw new Error("Invalid PNG file.");
|
||||
}
|
||||
|
||||
let offset = pngSignature.length;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let bitDepth = 0;
|
||||
let colorType = 0;
|
||||
let interlaceMethod = 0;
|
||||
const idatChunks: Buffer[] = [];
|
||||
|
||||
while (offset + 12 <= png.length) {
|
||||
const length = png.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
const type = png.toString("ascii", offset, offset + 4);
|
||||
offset += 4;
|
||||
const data = png.subarray(offset, offset + length);
|
||||
offset += length + 4;
|
||||
|
||||
if (type === "IHDR") {
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
bitDepth = data[8];
|
||||
colorType = data[9];
|
||||
interlaceMethod = data[12];
|
||||
} else if (type === "IDAT") {
|
||||
idatChunks.push(Buffer.from(data));
|
||||
} else if (type === "IEND") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0 || bitDepth !== 8 || (colorType !== 2 && colorType !== 6) || interlaceMethod !== 0) {
|
||||
throw new Error("Unsupported PNG format.");
|
||||
}
|
||||
|
||||
const channels = colorType === 6 ? 4 : 3;
|
||||
const stride = width * channels;
|
||||
const inflated = inflateSync(Buffer.concat(idatChunks));
|
||||
const pixels = new Uint8Array(width * height * channels);
|
||||
let sourceOffset = 0;
|
||||
let previousRow = new Uint8Array(stride);
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const filter = inflated[sourceOffset];
|
||||
sourceOffset += 1;
|
||||
const row = new Uint8Array(stride);
|
||||
for (let x = 0; x < stride; x += 1) {
|
||||
const raw = inflated[sourceOffset + x];
|
||||
const left = x >= channels ? row[x - channels] : 0;
|
||||
const up = previousRow[x] ?? 0;
|
||||
const upLeft = x >= channels ? previousRow[x - channels] ?? 0 : 0;
|
||||
row[x] = unfilterPngByte(filter, raw, left, up, upLeft);
|
||||
}
|
||||
pixels.set(row, y * stride);
|
||||
previousRow = row;
|
||||
sourceOffset += stride;
|
||||
}
|
||||
|
||||
return {
|
||||
bitDepth,
|
||||
colorType: colorType as 2 | 6,
|
||||
height,
|
||||
pixels,
|
||||
width
|
||||
};
|
||||
}
|
||||
|
||||
function unfilterPngByte(filter: number, raw: number, left: number, up: number, upLeft: number): number {
|
||||
if (filter === 0) return raw;
|
||||
if (filter === 1) return (raw + left) & 0xff;
|
||||
if (filter === 2) return (raw + up) & 0xff;
|
||||
if (filter === 3) return (raw + Math.floor((left + up) / 2)) & 0xff;
|
||||
if (filter === 4) return (raw + pngPaethPredictor(left, up, upLeft)) & 0xff;
|
||||
throw new Error(`Unsupported PNG filter: ${filter}`);
|
||||
}
|
||||
|
||||
function pngPaethPredictor(left: number, up: number, upLeft: number): number {
|
||||
const estimate = left + up - upLeft;
|
||||
const leftDistance = Math.abs(estimate - left);
|
||||
const upDistance = Math.abs(estimate - up);
|
||||
const upLeftDistance = Math.abs(estimate - upLeft);
|
||||
if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left;
|
||||
if (upDistance <= upLeftDistance) return up;
|
||||
return upLeft;
|
||||
}
|
||||
|
||||
function pngPixelsToRgba(decoded: DecodedPngPixels): Uint8Array {
|
||||
const rgba = new Uint8Array(decoded.width * decoded.height * 4);
|
||||
const sourceChannels = decoded.colorType === 6 ? 4 : 3;
|
||||
for (let source = 0, target = 0; source < decoded.pixels.length; source += sourceChannels, target += 4) {
|
||||
rgba[target] = decoded.pixels[source];
|
||||
rgba[target + 1] = decoded.pixels[source + 1];
|
||||
rgba[target + 2] = decoded.pixels[source + 2];
|
||||
rgba[target + 3] = sourceChannels === 4 ? decoded.pixels[source + 3] : 255;
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
function resizeRgbaBilinear(source: Uint8Array, sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): Uint8Array {
|
||||
const target = new Uint8Array(targetWidth * targetHeight * 4);
|
||||
const xRatio = targetWidth > 1 ? (sourceWidth - 1) / (targetWidth - 1) : 0;
|
||||
const yRatio = targetHeight > 1 ? (sourceHeight - 1) / (targetHeight - 1) : 0;
|
||||
|
||||
for (let y = 0; y < targetHeight; y += 1) {
|
||||
const sourceY = y * yRatio;
|
||||
const y0 = Math.floor(sourceY);
|
||||
const y1 = Math.min(sourceHeight - 1, y0 + 1);
|
||||
const yWeight = sourceY - y0;
|
||||
for (let x = 0; x < targetWidth; x += 1) {
|
||||
const sourceX = x * xRatio;
|
||||
const x0 = Math.floor(sourceX);
|
||||
const x1 = Math.min(sourceWidth - 1, x0 + 1);
|
||||
const xWeight = sourceX - x0;
|
||||
const targetOffset = (y * targetWidth + x) * 4;
|
||||
const topLeft = (y0 * sourceWidth + x0) * 4;
|
||||
const topRight = (y0 * sourceWidth + x1) * 4;
|
||||
const bottomLeft = (y1 * sourceWidth + x0) * 4;
|
||||
const bottomRight = (y1 * sourceWidth + x1) * 4;
|
||||
for (let channel = 0; channel < 4; channel += 1) {
|
||||
const top = source[topLeft + channel] * (1 - xWeight) + source[topRight + channel] * xWeight;
|
||||
const bottom = source[bottomLeft + channel] * (1 - xWeight) + source[bottomRight + channel] * xWeight;
|
||||
target[targetOffset + channel] = Math.round(top * (1 - yWeight) + bottom * yWeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function applyRoundedAlphaMask(rgba: Uint8Array, width: number, height: number, radius: number): void {
|
||||
if (radius <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
const innerHalfWidth = Math.max(0, halfWidth - radius);
|
||||
const innerHalfHeight = Math.max(0, halfHeight - radius);
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const qx = Math.abs(x + 0.5 - halfWidth) - innerHalfWidth;
|
||||
const qy = Math.abs(y + 0.5 - halfHeight) - innerHalfHeight;
|
||||
const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
|
||||
const inside = Math.min(Math.max(qx, qy), 0);
|
||||
const distance = outside + inside - radius;
|
||||
const coverage = Math.max(0, Math.min(1, 0.5 - distance));
|
||||
if (coverage >= 1) {
|
||||
continue;
|
||||
}
|
||||
const alphaOffset = (y * width + x) * 4 + 3;
|
||||
rgba[alphaOffset] = Math.round(rgba[alphaOffset] * coverage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRgbaPng(width: number, height: number, rgba: Uint8Array): Buffer {
|
||||
const stride = width * 4;
|
||||
const scanlines = Buffer.alloc((stride + 1) * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const target = y * (stride + 1);
|
||||
scanlines[target] = 0;
|
||||
scanlines.set(rgba.subarray(y * stride, (y + 1) * stride), target + 1);
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
return Buffer.concat([
|
||||
pngSignature,
|
||||
pngChunk("IHDR", ihdr),
|
||||
pngChunk("IDAT", deflateSync(scanlines)),
|
||||
pngChunk("IEND", Buffer.alloc(0))
|
||||
]);
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const chunk = Buffer.alloc(12 + data.length);
|
||||
chunk.writeUInt32BE(data.length, 0);
|
||||
typeBuffer.copy(chunk, 4);
|
||||
data.copy(chunk, 8);
|
||||
chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
let crc32Table: Uint32Array | undefined;
|
||||
|
||||
function crc32(buffer: Buffer): number {
|
||||
const table = crc32Table ?? createCrc32Table();
|
||||
crc32Table = table;
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function createCrc32Table(): Uint32Array {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < table.length; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
function readDataExportFiles(): Array<{ base64: string; name: string; path: string; sizeBytes: number }> {
|
||||
const files: Array<{ base64: string; name: string; path: string; sizeBytes: number }> = [];
|
||||
for (const file of dataExportCandidateFiles()) {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ import type {
|
|||
AgentAnalysisTracePayloadFullResult,
|
||||
AgentAnalysisTracePayloadRequest,
|
||||
AppConfig,
|
||||
AppCaptureElementPngRequest,
|
||||
AppCaptureElementPngResult,
|
||||
AppDataExportResult,
|
||||
AppInfo,
|
||||
AppImageExportTargetRequest,
|
||||
AppImageExportTargetResult,
|
||||
AppSaveConfigOptions,
|
||||
AppUpdateStatus,
|
||||
ApiKeyConfig,
|
||||
|
|
@ -86,6 +90,7 @@ contextBridge.exposeInMainWorld("ccr", {
|
|||
applyClaudeAppGateway: (config?: AppConfig) => invoke(IPC_CHANNELS.appApplyClaudeAppGateway, config) as Promise<ClaudeAppGatewayApplyResult>,
|
||||
applyProfile: () => invoke(IPC_CHANNELS.appApplyProfile) as Promise<ProfileApplyResult>,
|
||||
cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => invoke(IPC_CHANNELS.appBotGatewayQrLoginCancel, request) as Promise<BotGatewayQrLoginCancelResult>,
|
||||
captureElementPng: (request: AppCaptureElementPngRequest) => invoke(IPC_CHANNELS.appCaptureElementPng, request) as Promise<AppCaptureElementPngResult>,
|
||||
checkProviderConnectivity: (request: GatewayProviderConnectivityCheckRequest) => invoke(IPC_CHANNELS.appCheckProviderConnectivity, request) as Promise<GatewayProviderConnectivityCheckReport>,
|
||||
closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => invoke(IPC_CHANNELS.appBotGatewayQrWindowClose, request) as Promise<BotGatewayQrWindowCloseResult>,
|
||||
clearProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
|
||||
|
|
@ -121,6 +126,7 @@ contextBridge.exposeInMainWorld("ccr", {
|
|||
openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => invoke(IPC_CHANNELS.appBotGatewayQrWindowOpen, request) as Promise<BotGatewayQrWindowOpenResult>,
|
||||
openExternal: (url: string) => invoke(IPC_CHANNELS.appOpenExternal, url) as Promise<void>,
|
||||
openProfile: (request: ProfileOpenRequest) => invoke(IPC_CHANNELS.appOpenProfile, request) as Promise<ProfileOpenResult>,
|
||||
prepareImageExportTarget: (request: AppImageExportTargetRequest) => invoke(IPC_CHANNELS.appPrepareImageExportTarget, request) as Promise<AppImageExportTargetResult>,
|
||||
probeProviderCandidates: (request: GatewayProviderProbeCandidatesRequest) => invoke(IPC_CHANNELS.appProbeProviderCandidates, request) as Promise<GatewayProviderProbeCandidateResult | undefined>,
|
||||
probeProvider: (request: GatewayProviderProbeRequest) => invoke(IPC_CHANNELS.appProbeProvider, request) as Promise<GatewayProviderProbeResult>,
|
||||
quitApp: () => invoke(IPC_CHANNELS.appQuit) as Promise<void>,
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ const moonshotChinaProviderAccountConfig: ProviderAccountConfig = {
|
|||
export const moonshotChinaProviderPreset: ProviderPreset = {
|
||||
account: moonshotChinaProviderAccountConfig,
|
||||
aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"],
|
||||
defaultModels: ["moonshot-v1-8k"],
|
||||
defaultModels: ["kimi-k2.7-code"],
|
||||
endpoints: [
|
||||
{
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
|
|
@ -92,7 +92,7 @@ export const moonshotChinaProviderPreset: ProviderPreset = {
|
|||
export const moonshotGlobalProviderPreset: ProviderPreset = {
|
||||
account: moonshotGlobalProviderAccountConfig,
|
||||
aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"],
|
||||
defaultModels: ["moonshot-v1-8k"],
|
||||
defaultModels: ["kimi-k2.7-code"],
|
||||
endpoints: [
|
||||
{
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
useState, X, XAxis, YAxis
|
||||
} from "../shared";
|
||||
import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity";
|
||||
import { ShareCardWidget } from "./share-cards";
|
||||
export function OverviewView({
|
||||
onWidgetsChange,
|
||||
overviewWidgets,
|
||||
|
|
@ -223,6 +224,17 @@ export function OverviewView({
|
|||
});
|
||||
}
|
||||
|
||||
function changeWidgetShareData(id: string, type: ShareOverviewWidgetType) {
|
||||
const current = widgets.find((widget) => widget.id === id);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
updateWidget(id, {
|
||||
type,
|
||||
variant: overviewWidgetVariantOptions(type)[0]?.value ?? current.variant
|
||||
});
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
onWidgetsChange(DEFAULT_OVERVIEW_WIDGETS.map((widget) => ({ ...widget })));
|
||||
setSelectedWidgetId(undefined);
|
||||
|
|
@ -345,6 +357,7 @@ export function OverviewView({
|
|||
onChangeBreakdownData={(type) => selectedWidget ? changeWidgetBreakdownData(selectedWidget.id, type) : undefined}
|
||||
onChangeCategory={(category) => selectedWidget ? changeWidgetCategory(selectedWidget.id, category) : undefined}
|
||||
onChangeMetric={(metric) => selectedWidget ? updateWidget(selectedWidget.id, { metric }) : undefined}
|
||||
onChangeShareData={(type) => selectedWidget ? changeWidgetShareData(selectedWidget.id, type) : undefined}
|
||||
onChangeSize={(size) => selectedWidget ? updateWidget(selectedWidget.id, { size }) : undefined}
|
||||
onChangeVariant={(variant) => selectedWidget ? updateWidget(selectedWidget.id, { variant }) : undefined}
|
||||
onRemove={() => selectedWidget ? removeWidget(selectedWidget.id) : undefined}
|
||||
|
|
@ -354,6 +367,7 @@ export function OverviewView({
|
|||
) : (
|
||||
widgetGrid
|
||||
)}
|
||||
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -419,8 +433,8 @@ function OverviewWidgetPalette({
|
|||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{t(overviewWidgetCategoryLabel(overviewWidgetCategory(template.type)))}</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">{t(overviewWidgetCategoryDescription(overviewWidgetCategory(template.type)))}</div>
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{t(overviewWidgetPaletteTitle(template))}</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">{t(overviewWidgetPaletteDescription(template))}</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
|
|
@ -436,6 +450,7 @@ function OverviewWidgetProperties({
|
|||
onChangeBreakdownData,
|
||||
onChangeCategory,
|
||||
onChangeMetric,
|
||||
onChangeShareData,
|
||||
onChangeSize,
|
||||
onChangeVariant,
|
||||
onRemove
|
||||
|
|
@ -447,6 +462,7 @@ function OverviewWidgetProperties({
|
|||
onChangeBreakdownData: (type: "model-distribution" | "token-mix") => void;
|
||||
onChangeCategory: (category: OverviewWidgetCategory) => void;
|
||||
onChangeMetric: (metric: OverviewMetricKind) => void;
|
||||
onChangeShareData: (type: ShareOverviewWidgetType) => void;
|
||||
onChangeSize: (size: OverviewWidgetSize) => void;
|
||||
onChangeVariant: (variant: OverviewWidgetVariant) => void;
|
||||
onRemove: () => void;
|
||||
|
|
@ -480,6 +496,9 @@ function OverviewWidgetProperties({
|
|||
if (category === "breakdown") {
|
||||
onChangeBreakdownData(value as "model-distribution" | "token-mix");
|
||||
}
|
||||
if (category === "share-card") {
|
||||
onChangeShareData(value as ShareOverviewWidgetType);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -847,6 +866,8 @@ function OverviewWidgetRenderer({
|
|||
content = <ModelDistributionOverviewWidget dimensions={dimensions} rows={usageStats.models} variant={overviewTokenMixVariant(widget.variant)} />;
|
||||
} else if (widget.type === "client-analysis") {
|
||||
content = <OverviewAnalysisWidget dimensions={dimensions} kind="client" rows={usageStats.clientModels} variant={widget.variant === "compact" ? "compact" : "table"} />;
|
||||
} else if (isShareOverviewWidgetType(widget.type)) {
|
||||
content = <ShareCardWidget providerAccounts={providerAccounts} type={widget.type} usageRange={usageRange} usageStats={usageStats} />;
|
||||
} else {
|
||||
content = <OverviewAnalysisWidget dimensions={dimensions} kind="provider" rows={usageStats.providerModels} variant={widget.variant === "compact" ? "compact" : "table"} />;
|
||||
}
|
||||
|
|
@ -1464,11 +1485,19 @@ function overviewWidgetTemplates(): OverviewWidgetConfig[] {
|
|||
{ enabled: true, id: "usage-trend", size: "3:2", type: "usage-trend", variant: "composed" },
|
||||
{ enabled: true, id: "token-activity", size: "4:2", type: "token-activity", variant: "heatmap" },
|
||||
{ enabled: true, id: "token-mix", size: "1:2", type: "token-mix", variant: "bars" },
|
||||
{ enabled: true, id: "client-analysis", size: "2:2", type: "client-analysis", variant: "table" }
|
||||
{ enabled: true, id: "client-analysis", size: "2:2", type: "client-analysis", variant: "table" },
|
||||
{ enabled: true, id: "share-usage-wrapped", size: "1:4", type: "share-usage-wrapped", variant: "card" },
|
||||
{ enabled: true, id: "share-route-map", size: "1:4", type: "share-route-map", variant: "card" },
|
||||
{ enabled: true, id: "share-model-leaderboard", size: "1:4", type: "share-model-leaderboard", variant: "card" },
|
||||
{ enabled: true, id: "share-fuel-cockpit", size: "1:4", type: "share-fuel-cockpit", variant: "card" },
|
||||
{ enabled: true, id: "share-token-calendar", size: "1:4", type: "share-token-calendar", variant: "card" },
|
||||
{ enabled: true, id: "share-spend-receipt", size: "1:4", type: "share-spend-receipt", variant: "card" }
|
||||
];
|
||||
}
|
||||
|
||||
type OverviewWidgetCategory = "account-balance" | "activity" | "analysis" | "breakdown" | "metric" | "system-status" | "usage-trend";
|
||||
type ShareOverviewWidgetType = Extract<OverviewWidgetType, "share-fuel-cockpit" | "share-model-leaderboard" | "share-route-map" | "share-spend-receipt" | "share-token-calendar" | "share-usage-wrapped">;
|
||||
|
||||
type OverviewWidgetCategory = "account-balance" | "activity" | "analysis" | "breakdown" | "metric" | "share-card" | "system-status" | "usage-trend";
|
||||
|
||||
function overviewWidgetCategoryOptions(): Array<{ label: string; value: OverviewWidgetCategory }> {
|
||||
return [
|
||||
|
|
@ -1478,7 +1507,8 @@ function overviewWidgetCategoryOptions(): Array<{ label: string; value: Overview
|
|||
"usage-trend",
|
||||
"activity",
|
||||
"breakdown",
|
||||
"analysis"
|
||||
"analysis",
|
||||
"share-card"
|
||||
].map((category) => ({
|
||||
label: overviewWidgetCategoryLabel(category as OverviewWidgetCategory),
|
||||
value: category as OverviewWidgetCategory
|
||||
|
|
@ -1499,6 +1529,17 @@ function overviewBreakdownDataOptions(): Array<{ label: string; value: "model-di
|
|||
];
|
||||
}
|
||||
|
||||
function overviewShareCardDataOptions(): Array<{ label: string; value: ShareOverviewWidgetType }> {
|
||||
return [
|
||||
{ label: "AI Usage Wrapped", value: "share-usage-wrapped" },
|
||||
{ label: "CCR Route Map", value: "share-route-map" },
|
||||
{ label: "Model Leaderboard", value: "share-model-leaderboard" },
|
||||
{ label: "AI Fuel Cockpit", value: "share-fuel-cockpit" },
|
||||
{ label: "Token Calendar Poster", value: "share-token-calendar" },
|
||||
{ label: "Spend Receipt", value: "share-spend-receipt" }
|
||||
];
|
||||
}
|
||||
|
||||
function overviewWidgetDataOptions(widget: OverviewWidgetConfig, providerAccounts: ProviderAccountSnapshot[]): Array<{ label: string; value: string }> {
|
||||
const category = overviewWidgetCategory(widget.type);
|
||||
if (category === "metric") {
|
||||
|
|
@ -1526,6 +1567,9 @@ function overviewWidgetDataOptions(widget: OverviewWidgetConfig, providerAccount
|
|||
if (category === "breakdown") {
|
||||
return overviewBreakdownDataOptions();
|
||||
}
|
||||
if (category === "share-card") {
|
||||
return overviewShareCardDataOptions();
|
||||
}
|
||||
return [{ label: "Usage over time", value: "usage-trend" }];
|
||||
}
|
||||
|
||||
|
|
@ -1546,6 +1590,9 @@ function overviewWidgetDataValue(widget: OverviewWidgetConfig): string {
|
|||
if (category === "activity") {
|
||||
return "token-activity";
|
||||
}
|
||||
if (category === "share-card") {
|
||||
return widget.type;
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
|
|
@ -1559,6 +1606,9 @@ function overviewWidgetCategory(type: OverviewWidgetType): OverviewWidgetCategor
|
|||
if (type === "token-activity") {
|
||||
return "activity";
|
||||
}
|
||||
if (isShareOverviewWidgetType(type)) {
|
||||
return "share-card";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
|
|
@ -1572,10 +1622,16 @@ function overviewWidgetTypeForCategory(category: OverviewWidgetCategory, current
|
|||
if (category === "activity") {
|
||||
return "token-activity";
|
||||
}
|
||||
if (category === "share-card") {
|
||||
return isShareOverviewWidgetType(currentType) ? currentType : "share-usage-wrapped";
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
function overviewWidgetTemplateKey(widget: OverviewWidgetConfig): string {
|
||||
if (isShareOverviewWidgetType(widget.type)) {
|
||||
return widget.type;
|
||||
}
|
||||
return overviewWidgetCategory(widget.type);
|
||||
}
|
||||
|
||||
|
|
@ -1584,6 +1640,7 @@ function overviewWidgetCategoryLabel(category: OverviewWidgetCategory): string {
|
|||
if (category === "analysis") return "Analysis component";
|
||||
if (category === "activity") return "Activity component";
|
||||
if (category === "metric") return "Metric component";
|
||||
if (category === "share-card") return "Share card";
|
||||
if (category === "system-status") return "Status component";
|
||||
if (category === "breakdown") return "Breakdown component";
|
||||
return "Trend component";
|
||||
|
|
@ -1594,11 +1651,24 @@ function overviewWidgetCategoryDescription(category: OverviewWidgetCategory): st
|
|||
if (category === "analysis") return "Client or provider";
|
||||
if (category === "activity") return "Token activity heatmap";
|
||||
if (category === "metric") return "Requests, tokens, cost";
|
||||
if (category === "share-card") return "Social media PNG cards";
|
||||
if (category === "system-status") return "Status timeline";
|
||||
if (category === "breakdown") return "Token or model distribution";
|
||||
return "Usage over time";
|
||||
}
|
||||
|
||||
function overviewWidgetPaletteTitle(widget: OverviewWidgetConfig): string {
|
||||
return isShareOverviewWidgetType(widget.type)
|
||||
? overviewWidgetTypeLabel(widget.type)
|
||||
: overviewWidgetCategoryLabel(overviewWidgetCategory(widget.type));
|
||||
}
|
||||
|
||||
function overviewWidgetPaletteDescription(widget: OverviewWidgetConfig): string {
|
||||
return isShareOverviewWidgetType(widget.type)
|
||||
? overviewWidgetCategoryLabel("share-card")
|
||||
: overviewWidgetCategoryDescription(overviewWidgetCategory(widget.type));
|
||||
}
|
||||
|
||||
function overviewWidgetTitle(widget: OverviewWidgetConfig, translate: (value: string) => string): string {
|
||||
if (widget.type === "metric") {
|
||||
return translate(overviewMetricLabel(widget.metric ?? "requests"));
|
||||
|
|
@ -1612,6 +1682,12 @@ function overviewWidgetTypeLabel(type: OverviewWidgetType): string {
|
|||
if (type === "metric") return "Metric";
|
||||
if (type === "model-distribution") return "Model Distribution";
|
||||
if (type === "provider-analysis") return "Provider Analysis";
|
||||
if (type === "share-fuel-cockpit") return "AI Fuel Cockpit";
|
||||
if (type === "share-model-leaderboard") return "Model Leaderboard";
|
||||
if (type === "share-route-map") return "CCR Route Map";
|
||||
if (type === "share-spend-receipt") return "Spend Receipt";
|
||||
if (type === "share-token-calendar") return "Token Calendar Poster";
|
||||
if (type === "share-usage-wrapped") return "AI Usage Wrapped";
|
||||
if (type === "system-status") return "System status";
|
||||
if (type === "token-activity") return "Activity";
|
||||
if (type === "token-mix") return "Token Mix";
|
||||
|
|
@ -1665,12 +1741,26 @@ function overviewWidgetVariantOptions(type: OverviewWidgetType): Array<{ label:
|
|||
{ label: "Compact", value: "compact" }
|
||||
];
|
||||
}
|
||||
if (isShareOverviewWidgetType(type)) {
|
||||
return [
|
||||
{ label: "Card", value: "card" }
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: "Table", value: "table" },
|
||||
{ label: "Compact", value: "compact" }
|
||||
];
|
||||
}
|
||||
|
||||
function isShareOverviewWidgetType(type: OverviewWidgetType): type is ShareOverviewWidgetType {
|
||||
return type === "share-fuel-cockpit" ||
|
||||
type === "share-model-leaderboard" ||
|
||||
type === "share-route-map" ||
|
||||
type === "share-spend-receipt" ||
|
||||
type === "share-token-calendar" ||
|
||||
type === "share-usage-wrapped";
|
||||
}
|
||||
|
||||
function overviewWidgetSizeClass(size: OverviewWidgetSize): string {
|
||||
const { height, width } = overviewWidgetDimensions(size);
|
||||
return cn(overviewWidgetWidthClass(width), overviewWidgetHeightClass(height));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import {
|
|||
AddProviderDraft, AnimatedDisclosure, AnimatedIconSwap, AnimatedListItem, AnimatedPopover, AnimatePresence, AppConfig, Badge,
|
||||
Box, Braces, Button, Card, CardContent, CardHeader, CardTitle,
|
||||
Check, Checkbox, ChevronDown, ChevronRight, CircleAlert, cn,
|
||||
compareProviderAccountSnapshots, Copy, copyTextToClipboard, createDefaultProviderAccountDraft, createModelCatalogItems, createProviderAccountDraftFromConfig, createProviderCredentialDraft, createProviderInstallLinkFromDraft,
|
||||
compareProviderAccountSnapshots, copyTextToClipboard, createDefaultProviderAccountDraft, createModelCatalogItems, createProviderAccountDraftFromConfig, createProviderCredentialDraft,
|
||||
customProviderPresetId, defaultProviderAccountConfigForPreset, Dialog, DialogBody, DialogContent, DialogFooter,
|
||||
DialogHeader, DialogTitle, ExternalLink, Field, findProviderPreset, formatProviderAccountMeterValue, GatewayProviderConfig,
|
||||
GatewayProviderProbeResult, getProviderPresets, Globe, inferProviderNameFromBaseUrl, Input, KeyValueRowsControl, Label,
|
||||
|
|
@ -624,7 +624,7 @@ function ProviderPresetCombobox({
|
|||
const [query, setQuery] = useState("");
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const selected = options.find((option) => option.value === value) ?? options[0];
|
||||
const selected = options.find((option) => option.value === value) ?? options.find((option) => option.value === "");
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredOptions = normalizedQuery
|
||||
? options.filter((option) => providerPresetOptionMatchesQuery(option, normalizedQuery))
|
||||
|
|
@ -1232,9 +1232,9 @@ export function AddProviderForm({
|
|||
const safetyIssue = providerDraftSafetyIssue(draft, detectedBaseUrl);
|
||||
const localAgentImport = draft.providerPlugins.length > 0;
|
||||
const providerPresetOptions = [
|
||||
{ iconUrl: draft.icon, label: t("Other / custom API endpoint"), value: customProviderPresetId },
|
||||
{ label: t("Select preset provider"), value: "" },
|
||||
...getProviderPresets().map((preset) => ({ label: t(preset.name), preset, value: preset.id })),
|
||||
{ iconUrl: draft.icon, label: t("Other / custom API endpoint"), value: customProviderPresetId }
|
||||
...getProviderPresets().map((preset) => ({ label: t(preset.name), preset, value: preset.id }))
|
||||
];
|
||||
const selectableProtocols = providerSelectableProtocolsFromProbe(probe);
|
||||
const hasConnectivityCheckInputs = Boolean(
|
||||
|
|
@ -1800,7 +1800,6 @@ function ProviderUsageSettings({
|
|||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [testResult, setTestResult] = useState<ProviderAccountTestResult>();
|
||||
const [testError, setTestError] = useState("");
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const modeOptions = translateOptions(providerAccountModeOptions, t);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1808,14 +1807,6 @@ function ProviderUsageSettings({
|
|||
setTestError("");
|
||||
}, [draft.accountMode, draft.usageRequestUrl, draft.usageRequestMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copiedLink) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setCopiedLink(false), 1300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copiedLink]);
|
||||
|
||||
async function testUsageRequest() {
|
||||
if (!window.ccr?.testProviderAccountConnector) {
|
||||
setTestError(t("Request failed."));
|
||||
|
|
@ -1855,17 +1846,6 @@ function ProviderUsageSettings({
|
|||
}
|
||||
}
|
||||
|
||||
async function copyProviderPluginLink() {
|
||||
const link = createProviderInstallLinkFromDraft(draft, probe);
|
||||
if (typeof link === "string" && link.startsWith("ccr://")) {
|
||||
await copyTextToClipboard(link);
|
||||
setCopiedLink(true);
|
||||
setTestError("");
|
||||
return;
|
||||
}
|
||||
setTestError(formatError(new Error(link)));
|
||||
}
|
||||
|
||||
function selectPath(target: ProviderUsageFieldTarget, path: string) {
|
||||
onChange(providerUsageFieldPatch(target, path));
|
||||
}
|
||||
|
|
@ -1880,12 +1860,6 @@ function ProviderUsageSettings({
|
|||
/>
|
||||
<span className="min-w-0 truncate">{t("Fetch usage")}</span>
|
||||
</Label>
|
||||
<Button className="h-8 shrink-0 px-2" onClick={() => void copyProviderPluginLink()} type="button" variant="outline">
|
||||
<AnimatedIconSwap iconKey={copiedLink ? "copied" : "copy"}>
|
||||
{copiedLink ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</AnimatedIconSwap>
|
||||
{copiedLink ? t("Copied") : t("Copy provider plugin link")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{draft.accountEnabled ? (
|
||||
|
|
|
|||
775
src/renderer/pages/home/components/share-cards.tsx
Normal file
775
src/renderer/pages/home/components/share-cards.tsx
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
import {
|
||||
Button, cn, Download, formatCompactNumber, formatDuration, formatPercent, formatProviderAccountMeterTitle,
|
||||
formatProviderAccountMeterValue, formatUsdCost, LoaderCircle, primaryProviderAccountMeter, providerAccountMeterRemainingRatio,
|
||||
providerAccountSnapshotKey, ReactNode, UsageStatsRange, UsageStatsSnapshot, useRef, useState,
|
||||
useAppText, type OverviewWidgetType, type ProviderAccountMeter, type ProviderAccountSnapshot
|
||||
} from "../shared";
|
||||
import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity";
|
||||
|
||||
type ShareCardTone = "amber" | "blue" | "emerald" | "indigo" | "rose" | "slate" | "teal";
|
||||
|
||||
type ShareCardWidgetProps = {
|
||||
providerAccounts: ProviderAccountSnapshot[];
|
||||
type: OverviewWidgetType;
|
||||
usageRange: UsageStatsRange;
|
||||
usageStats: UsageStatsSnapshot;
|
||||
};
|
||||
|
||||
type ShareCardPngExportOptions = {
|
||||
exportId?: string;
|
||||
output?: {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ShareCardPreparedExportTarget = {
|
||||
canceled: boolean;
|
||||
exportId?: string;
|
||||
};
|
||||
|
||||
const shareCardExportCssWidth = 540;
|
||||
const shareCardExportCssHeight = 960;
|
||||
const shareCardExportPixelWidth = 1080;
|
||||
const shareCardExportPixelHeight = 1920;
|
||||
|
||||
const shareCardTones: Record<ShareCardTone, { accent: string; background: string; border: string; glow: string; muted: string; text: string }> = {
|
||||
amber: {
|
||||
accent: "#f59e0b",
|
||||
background: "linear-gradient(135deg, #fffbeb 0%, #fef3c7 42%, #fff7ed 100%)",
|
||||
border: "border-amber-200",
|
||||
glow: "bg-amber-300/28",
|
||||
muted: "text-amber-900/62",
|
||||
text: "text-amber-950"
|
||||
},
|
||||
blue: {
|
||||
accent: "#2563eb",
|
||||
background: "linear-gradient(135deg, #eff6ff 0%, #dbeafe 44%, #ecfeff 100%)",
|
||||
border: "border-blue-200",
|
||||
glow: "bg-blue-300/30",
|
||||
muted: "text-blue-950/62",
|
||||
text: "text-blue-950"
|
||||
},
|
||||
emerald: {
|
||||
accent: "#059669",
|
||||
background: "linear-gradient(135deg, #ecfdf5 0%, #d1fae5 42%, #f0fdfa 100%)",
|
||||
border: "border-emerald-200",
|
||||
glow: "bg-emerald-300/28",
|
||||
muted: "text-emerald-950/62",
|
||||
text: "text-emerald-950"
|
||||
},
|
||||
indigo: {
|
||||
accent: "#4f46e5",
|
||||
background: "linear-gradient(135deg, #eef2ff 0%, #e0e7ff 46%, #f5f3ff 100%)",
|
||||
border: "border-indigo-200",
|
||||
glow: "bg-indigo-300/30",
|
||||
muted: "text-indigo-950/62",
|
||||
text: "text-indigo-950"
|
||||
},
|
||||
rose: {
|
||||
accent: "#e11d48",
|
||||
background: "linear-gradient(135deg, #fff1f2 0%, #ffe4e6 45%, #fff7ed 100%)",
|
||||
border: "border-rose-200",
|
||||
glow: "bg-rose-300/28",
|
||||
muted: "text-rose-950/62",
|
||||
text: "text-rose-950"
|
||||
},
|
||||
slate: {
|
||||
accent: "#475569",
|
||||
background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 48%, #f1f5f9 100%)",
|
||||
border: "border-slate-200",
|
||||
glow: "bg-slate-300/32",
|
||||
muted: "text-slate-700",
|
||||
text: "text-slate-950"
|
||||
},
|
||||
teal: {
|
||||
accent: "#0f766e",
|
||||
background: "linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 42%, #ecfeff 100%)",
|
||||
border: "border-teal-200",
|
||||
glow: "bg-teal-300/30",
|
||||
muted: "text-teal-950/62",
|
||||
text: "text-teal-950"
|
||||
}
|
||||
};
|
||||
|
||||
export function ShareCardWidget({
|
||||
providerAccounts,
|
||||
type,
|
||||
usageRange,
|
||||
usageStats
|
||||
}: ShareCardWidgetProps) {
|
||||
const t = useAppText();
|
||||
const definition = shareCardDefinition(type);
|
||||
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShareCardShell fileName={definition.fileName} title={t(definition.title)}>
|
||||
{type === "share-usage-wrapped" ? <UsageWrappedCard usageStats={usageStats} /> : null}
|
||||
{type === "share-route-map" ? <RouteMapCard usageStats={usageStats} /> : null}
|
||||
{type === "share-model-leaderboard" ? <ModelLeaderboardCard usageStats={usageStats} /> : null}
|
||||
{type === "share-fuel-cockpit" ? <FuelCockpitCard providerAccounts={providerAccounts} /> : null}
|
||||
{type === "share-token-calendar" ? <TokenCalendarPosterCard usageStats={usageStats} /> : null}
|
||||
{type === "share-spend-receipt" ? <SpendReceiptCard usageRange={usageRange} usageStats={usageStats} /> : null}
|
||||
</ShareCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
function shareCardDefinition(type: OverviewWidgetType): { fileName: string; title: string } | undefined {
|
||||
if (type === "share-usage-wrapped") return { fileName: "ccr-ai-usage-wrapped.png", title: "AI Usage Wrapped" };
|
||||
if (type === "share-route-map") return { fileName: "ccr-route-map.png", title: "CCR Route Map" };
|
||||
if (type === "share-model-leaderboard") return { fileName: "ccr-model-leaderboard.png", title: "Model Leaderboard" };
|
||||
if (type === "share-fuel-cockpit") return { fileName: "ccr-ai-fuel-cockpit.png", title: "AI Fuel Cockpit" };
|
||||
if (type === "share-token-calendar") return { fileName: "ccr-token-calendar-poster.png", title: "Token Calendar Poster" };
|
||||
if (type === "share-spend-receipt") return { fileName: "ccr-spend-receipt.png", title: "Spend Receipt" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ShareCardShell({
|
||||
children,
|
||||
fileName,
|
||||
title
|
||||
}: {
|
||||
children: ReactNode;
|
||||
fileName: string;
|
||||
title: string;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const exportCardRef = useRef<HTMLDivElement>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [status, setStatus] = useState<"error" | "idle" | "saving">("idle");
|
||||
|
||||
async function saveImage() {
|
||||
if (status === "saving") {
|
||||
return;
|
||||
}
|
||||
setStatus("saving");
|
||||
let exportTarget: ShareCardPreparedExportTarget | undefined;
|
||||
try {
|
||||
if (window.ccr?.prepareImageExportTarget) {
|
||||
exportTarget = await window.ccr.prepareImageExportTarget({ fileName });
|
||||
if (exportTarget.canceled) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setExporting(true);
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
const target = exportCardRef.current ?? cardRef.current;
|
||||
if (!target) {
|
||||
throw new Error("Export card is unavailable.");
|
||||
}
|
||||
await saveElementAsPng(target, fileName, {
|
||||
exportId: exportTarget?.exportId,
|
||||
output: {
|
||||
height: shareCardExportPixelHeight,
|
||||
width: shareCardExportPixelWidth
|
||||
}
|
||||
});
|
||||
setStatus("idle");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus("error");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="flex h-full min-h-0 min-w-0 flex-col space-y-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-[12px] font-semibold text-muted-foreground">{title}</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{status === "error" ? <span className="text-[11px] font-medium text-destructive">{t("Export failed")}</span> : null}
|
||||
<Button disabled={status === "saving"} size="sm" type="button" variant="outline" onClick={() => void saveImage()}>
|
||||
{status === "saving" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
|
||||
{status === "saving" ? t("Saving") : t("Save image")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1" ref={cardRef}>
|
||||
{children}
|
||||
</div>
|
||||
{exporting ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed left-0 top-0 z-[9999]"
|
||||
ref={exportCardRef}
|
||||
style={{
|
||||
height: `${shareCardExportCssHeight}px`,
|
||||
width: `${shareCardExportCssWidth}px`
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageWrappedCard({ usageStats }: { usageStats: UsageStatsSnapshot }) {
|
||||
const t = useAppText();
|
||||
const totals = usageStats.totals;
|
||||
const topModel = usageStats.models[0];
|
||||
const topProvider = usageStats.providerModels[0];
|
||||
const bestDay = [...usageStats.series].sort((a, b) => b.totalTokens - a.totalTokens)[0];
|
||||
const activity = buildTokenActivity(usageStats.series, { maxWeeks: 12, minWeeks: 8 });
|
||||
|
||||
return (
|
||||
<SharePoster tone="teal">
|
||||
<SharePosterHeader title={t("AI Usage Wrapped")} tone="teal" />
|
||||
<div className="mt-8">
|
||||
<div className="text-[60px] font-black leading-none tracking-normal text-teal-950">{formatCompactNumber(totals.totalTokens)}</div>
|
||||
<div className="mt-2 text-[17px] font-semibold text-teal-950/70">{t("tokens routed through CCR")}</div>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-2 gap-3">
|
||||
<PosterStat label={t("Requests")} value={formatCompactNumber(totals.requestCount)} />
|
||||
<PosterStat label={t("Estimated cost")} value={formatUsdCost(totals.costUsd)} />
|
||||
<PosterStat label={t("Cache ratio")} value={formatPercent(totals.cacheRatio)} />
|
||||
<PosterStat label={t("Longest streak")} value={`${formatCompactNumber(activity.longestStreak)} ${t(activity.longestStreak === 1 ? "day" : "days")}`} />
|
||||
</div>
|
||||
<div className="mt-8 grid gap-3">
|
||||
<PosterHighlight label={t("Top model")} value={topModel?.label ?? t("No model activity")} />
|
||||
<PosterHighlight label={t("Top provider")} value={topProvider?.provider || topProvider?.label || t("No provider activity")} />
|
||||
<PosterHighlight label={t("Peak day")} value={bestDay ? `${bestDay.label} / ${formatCompactNumber(bestDay.totalTokens)}` : "-"} />
|
||||
</div>
|
||||
<SharePosterFooter tone="teal" />
|
||||
</SharePoster>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteMapCard({ usageStats }: { usageStats: UsageStatsSnapshot }) {
|
||||
const t = useAppText();
|
||||
const routes = buildRouteRows(usageStats, t);
|
||||
const total = routes.reduce((sum, row) => sum + row.value, 0) || 1;
|
||||
|
||||
return (
|
||||
<SharePoster tone="indigo">
|
||||
<SharePosterHeader title={t("CCR Route Map")} tone="indigo" />
|
||||
<div className="mt-7 rounded-[22px] border border-indigo-200/80 bg-white/62 p-4 shadow-[0_16px_34px_rgba(79,70,229,0.10)]">
|
||||
<div className="grid grid-cols-[1fr_64px_1fr] items-center gap-3 text-center text-[10px] font-bold uppercase text-indigo-950/55">
|
||||
<span>{t("Client")}</span>
|
||||
<span>CCR</span>
|
||||
<span>{t("Model")}</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{routes.length > 0 ? routes.slice(0, 5).map((route, index) => {
|
||||
const share = Math.max(8, (route.value / total) * 100);
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_64px_1fr] items-center gap-3" key={`${route.client}-${route.provider}-${route.model}-${index}`}>
|
||||
<RoutePill label={route.client} />
|
||||
<div className="relative flex h-9 items-center justify-center">
|
||||
<span className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-indigo-200" />
|
||||
<span className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-indigo-500" style={{ width: `${share}%` }} />
|
||||
<span className="relative flex h-9 w-9 items-center justify-center rounded-full bg-indigo-600 text-[10px] font-black text-white shadow-[0_8px_18px_rgba(79,70,229,0.32)]">{index + 1}</span>
|
||||
</div>
|
||||
<RoutePill label={`${route.provider} / ${route.model}`} align="right" />
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-[18px] border border-dashed border-indigo-300 bg-indigo-50/60 px-4 py-12 text-center text-[14px] font-semibold text-indigo-950/55">
|
||||
{t("No route activity")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-3 gap-2">
|
||||
<MiniRouteMetric label={t("Clients")} value={formatCompactNumber(uniqueCount(routes.map((row) => row.client)))} />
|
||||
<MiniRouteMetric label={t("Providers")} value={formatCompactNumber(uniqueCount(routes.map((row) => row.provider)))} />
|
||||
<MiniRouteMetric label={t("Models")} value={formatCompactNumber(uniqueCount(routes.map((row) => row.model)))} />
|
||||
</div>
|
||||
<SharePosterFooter tone="indigo" />
|
||||
</SharePoster>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelLeaderboardCard({ usageStats }: { usageStats: UsageStatsSnapshot }) {
|
||||
const t = useAppText();
|
||||
const rows = usageStats.models.filter((row) => row.totalTokens > 0).slice(0, 5);
|
||||
const max = Math.max(...rows.map((row) => row.totalTokens), 1);
|
||||
|
||||
return (
|
||||
<SharePoster tone="blue">
|
||||
<SharePosterHeader title={t("Model Leaderboard")} tone="blue" />
|
||||
<div className="mt-7 space-y-3">
|
||||
{rows.length > 0 ? rows.map((row, index) => (
|
||||
<div className="rounded-[20px] border border-blue-200/80 bg-white/66 p-3 shadow-[0_12px_28px_rgba(37,99,235,0.10)]" key={row.key}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[14px] bg-blue-600 text-[17px] font-black text-white">{index + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[15px] font-black text-blue-950">{row.label}</div>
|
||||
<div className="mt-0.5 truncate text-[11px] font-semibold text-blue-950/55">{row.provider ?? t("All providers")}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[16px] font-black text-blue-950">{formatPercent(row.maxShare)}</div>
|
||||
<div className="text-[10px] font-semibold text-blue-950/52">{formatCompactNumber(row.totalTokens)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-blue-100">
|
||||
<div className="h-full rounded-full bg-blue-600" style={{ width: `${Math.max(4, (row.totalTokens / max) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<EmptyPosterState label={t("No model activity")} tone="blue" />
|
||||
)}
|
||||
</div>
|
||||
<SharePosterFooter tone="blue" />
|
||||
</SharePoster>
|
||||
);
|
||||
}
|
||||
|
||||
function FuelCockpitCard({ providerAccounts }: { providerAccounts: ProviderAccountSnapshot[] }) {
|
||||
const t = useAppText();
|
||||
const accountRows = providerAccounts
|
||||
.filter((account) => account.meters.length > 0)
|
||||
.map((account) => ({
|
||||
account,
|
||||
meter: fuelMeter(account)
|
||||
}))
|
||||
.filter((row): row is { account: ProviderAccountSnapshot; meter: ProviderAccountMeter } => Boolean(row.meter))
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<SharePoster tone="emerald">
|
||||
<SharePosterHeader title={t("AI Fuel Cockpit")} tone="emerald" />
|
||||
<div className="mt-7 grid gap-4">
|
||||
{accountRows.length > 0 ? accountRows.map(({ account, meter }, index) => {
|
||||
const ratio = providerAccountMeterRemainingRatio(meter) ?? (meter.kind === "balance" ? 1 : 0);
|
||||
return (
|
||||
<div className="grid grid-cols-[112px_minmax(0,1fr)] items-center gap-4 rounded-[22px] border border-emerald-200/80 bg-white/66 p-4 shadow-[0_14px_30px_rgba(5,150,105,0.10)]" key={providerAccountSnapshotKey(account)}>
|
||||
<CircularGauge color={index === 0 ? "#059669" : index === 1 ? "#2563eb" : "#d97706"} ratio={ratio} value={formatPercent(ratio)} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[16px] font-black text-emerald-950">{safeProviderName(account.provider, t)}</div>
|
||||
<div className="mt-1 truncate text-[12px] font-semibold text-emerald-950/58">{formatProviderAccountMeterTitle(meter, t)}</div>
|
||||
<div className="mt-3 text-[26px] font-black leading-none text-emerald-950">{formatProviderAccountMeterValue(meter)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<EmptyPosterState label={t("No account balance connectors configured")} tone="emerald" />
|
||||
)}
|
||||
</div>
|
||||
<SharePosterFooter tone="emerald" />
|
||||
</SharePoster>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenCalendarPosterCard({ usageStats }: { usageStats: UsageStatsSnapshot }) {
|
||||
const t = useAppText();
|
||||
const activity = buildTokenActivity(usageStats.series, { maxWeeks: 26, minWeeks: 18 });
|
||||
|
||||
return (
|
||||
<SharePoster tone="rose">
|
||||
<SharePosterHeader title={t("Token Calendar Poster")} tone="rose" />
|
||||
<div className="mt-7 rounded-[22px] border border-rose-200/80 bg-white/66 p-4 shadow-[0_14px_30px_rgba(225,29,72,0.10)]">
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gap: "5px",
|
||||
gridTemplateColumns: `repeat(${activity.weekCount}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: "repeat(7, minmax(0, 1fr))"
|
||||
}}
|
||||
>
|
||||
{activity.cells.map((cell) => (
|
||||
<span
|
||||
className="aspect-square rounded-[5px]"
|
||||
key={cell.dateKey}
|
||||
style={{
|
||||
backgroundColor: calendarColor(cell.intensity, cell.inObservedRange),
|
||||
gridColumn: cell.weekIndex + 1,
|
||||
gridRow: cell.dayIndex + 1
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
<PosterStat label={t("Longest streak")} value={`${formatCompactNumber(activity.longestStreak)}d`} />
|
||||
<PosterStat label={t("Avg / day")} value={formatCompactNumber(Math.round(activity.avgPerDay))} />
|
||||
<PosterStat label={t("Total")} value={formatCompactNumber(activity.totalTokens)} />
|
||||
</div>
|
||||
<div className="mt-5 flex items-center gap-2 text-[12px] font-bold text-rose-950/62">
|
||||
<span>{t("Less")}</span>
|
||||
{[0, 1, 2, 3, 4].map((intensity) => (
|
||||
<span
|
||||
className="h-4 w-4 rounded-[5px]"
|
||||
key={intensity}
|
||||
style={{ backgroundColor: calendarColor(intensity as TokenActivityCell["intensity"], true) }}
|
||||
/>
|
||||
))}
|
||||
<span>{t("More")}</span>
|
||||
</div>
|
||||
<SharePosterFooter tone="rose" />
|
||||
</SharePoster>
|
||||
);
|
||||
}
|
||||
|
||||
function SpendReceiptCard({
|
||||
usageRange,
|
||||
usageStats
|
||||
}: {
|
||||
usageRange: UsageStatsRange;
|
||||
usageStats: UsageStatsSnapshot;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const totals = usageStats.totals;
|
||||
const rows = [
|
||||
{ label: t("Range"), value: rangeLabel(usageRange, t) },
|
||||
{ label: t("Requests"), value: formatCompactNumber(totals.requestCount) },
|
||||
{ label: t("Input tokens"), value: formatCompactNumber(totals.inputTokens) },
|
||||
{ label: t("Output tokens"), value: formatCompactNumber(totals.outputTokens) },
|
||||
{ label: t("Cache tokens"), value: formatCompactNumber(totals.cacheTokens) },
|
||||
{ label: t("Average latency"), value: formatDuration(totals.avgDurationMs) },
|
||||
{ label: t("Success rate"), value: formatPercent(totals.successRate) }
|
||||
];
|
||||
|
||||
return (
|
||||
<SharePoster tone="slate">
|
||||
<SharePosterHeader title={t("Spend Receipt")} tone="slate" />
|
||||
<div className="mt-7 rounded-[22px] border border-slate-300 bg-white/74 p-5 shadow-[0_16px_34px_rgba(71,85,105,0.12)]">
|
||||
<div className="border-b border-dashed border-slate-300 pb-4 text-center">
|
||||
<div className="text-[40px] font-black leading-none text-slate-950">{formatUsdCost(totals.costUsd)}</div>
|
||||
<div className="mt-2 text-[12px] font-bold uppercase text-slate-500">{t("Estimated cost")}</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2 border-b border-dashed border-slate-300 pb-4">
|
||||
{rows.map((row) => (
|
||||
<div className="flex min-w-0 items-center justify-between gap-4 text-[13px]" key={row.label}>
|
||||
<span className="truncate font-semibold text-slate-500">{row.label}</span>
|
||||
<span className="shrink-0 font-black text-slate-950">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-4">
|
||||
<div className="text-[11px] font-bold uppercase text-slate-500">{t("Total tokens")}</div>
|
||||
<div className="text-[30px] font-black leading-none text-slate-950">{formatCompactNumber(totals.totalTokens)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SharePosterFooter tone="slate" />
|
||||
</SharePoster>
|
||||
);
|
||||
}
|
||||
|
||||
function SharePoster({
|
||||
children,
|
||||
tone
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone: ShareCardTone;
|
||||
}) {
|
||||
const theme = shareCardTones[tone];
|
||||
return (
|
||||
<div
|
||||
className={cn("relative h-full min-h-0 w-full overflow-hidden rounded-[28px] border p-6 shadow-card-elevated", theme.border, theme.text)}
|
||||
style={{ background: theme.background }}
|
||||
>
|
||||
<span className={cn("absolute -right-16 -top-16 h-44 w-44 rounded-full blur-3xl", theme.glow)} />
|
||||
<span className={cn("absolute -bottom-20 left-8 h-44 w-44 rounded-full blur-3xl", theme.glow)} />
|
||||
<div className="relative z-10 flex h-full min-h-0 flex-col">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SharePosterHeader({
|
||||
title,
|
||||
tone
|
||||
}: {
|
||||
title: string;
|
||||
tone: ShareCardTone;
|
||||
}) {
|
||||
const theme = shareCardTones[tone];
|
||||
return (
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-[30px] font-black leading-[1.02] tracking-normal">{title}</h4>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[18px] border border-white/70 bg-white/58 shadow-[0_12px_28px_rgba(15,23,42,0.10)]">
|
||||
<span className="text-[16px] font-black" style={{ color: theme.accent }}>CCR</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SharePosterFooter({ tone }: { tone: ShareCardTone }) {
|
||||
const t = useAppText();
|
||||
const theme = shareCardTones[tone];
|
||||
return (
|
||||
<div className={cn("mt-auto flex min-w-0 items-center justify-between gap-3 pt-5 text-[11px] font-black uppercase tracking-[0.12em]", theme.muted)}>
|
||||
<span>{t("Generated by CCR")}</span>
|
||||
<span>{new Date().getFullYear()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PosterStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[18px] border border-white/70 bg-white/54 px-3 py-3 shadow-[0_10px_24px_rgba(15,23,42,0.08)]">
|
||||
<div className="truncate text-[11px] font-bold text-slate-600">{label}</div>
|
||||
<div className="mt-1 truncate text-[22px] font-black leading-none text-slate-950">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PosterHighlight({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 rounded-[18px] border border-white/70 bg-white/48 px-4 py-3">
|
||||
<span className="truncate text-[12px] font-bold text-slate-600">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-[14px] font-black text-slate-950">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPosterState({ label, tone }: { label: string; tone: ShareCardTone }) {
|
||||
const theme = shareCardTones[tone];
|
||||
return (
|
||||
<div className={cn("rounded-[22px] border border-dashed bg-white/52 px-4 py-16 text-center text-[14px] font-bold", theme.border, theme.muted)}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutePill({ align = "left", label }: { align?: "left" | "right"; label: string }) {
|
||||
return (
|
||||
<div className={cn("min-w-0 rounded-[16px] border border-indigo-200 bg-white/72 px-3 py-2 shadow-[0_8px_18px_rgba(79,70,229,0.08)]", align === "right" && "text-right")}>
|
||||
<div className="truncate text-[12px] font-black text-indigo-950">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniRouteMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[16px] border border-indigo-200/80 bg-white/58 px-3 py-2 text-center">
|
||||
<div className="text-[20px] font-black leading-none text-indigo-950">{value}</div>
|
||||
<div className="mt-1 truncate text-[10px] font-bold uppercase text-indigo-950/50">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CircularGauge({ color, ratio, value }: { color: string; ratio: number; value: string }) {
|
||||
const radius = 41;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = Math.max(0, Math.min(1, ratio));
|
||||
|
||||
return (
|
||||
<svg className="h-[112px] w-[112px]" role="img" viewBox="0 0 112 112" aria-label={value}>
|
||||
<circle cx="56" cy="56" fill="rgba(255,255,255,.55)" r="48" />
|
||||
<circle cx="56" cy="56" fill="none" r={radius} stroke="rgba(15,23,42,.10)" strokeWidth="10" />
|
||||
<circle
|
||||
cx="56"
|
||||
cy="56"
|
||||
fill="none"
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * (1 - clamped)}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="10"
|
||||
transform="rotate(-90 56 56)"
|
||||
/>
|
||||
<text fill="#064e3b" fontSize="18" fontWeight="900" textAnchor="middle" x="56" y="61">{value}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRouteRows(usageStats: UsageStatsSnapshot, translate: (value: string) => string): Array<{ client: string; model: string; provider: string; value: number }> {
|
||||
const rows = usageStats.clientModels
|
||||
.filter((row) => row.totalTokens > 0)
|
||||
.map((row) => ({
|
||||
client: row.client || row.label || translate("Agent"),
|
||||
model: row.model || modelNameFromLabel(row.label),
|
||||
provider: row.provider || translate("Provider"),
|
||||
value: row.totalTokens
|
||||
}));
|
||||
|
||||
if (rows.length > 0) {
|
||||
return rows.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
return usageStats.models
|
||||
.filter((row) => row.totalTokens > 0)
|
||||
.map((row) => ({
|
||||
client: translate("Agent"),
|
||||
model: row.model || row.label,
|
||||
provider: row.provider || translate("Provider"),
|
||||
value: row.totalTokens
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
function modelNameFromLabel(label: string): string {
|
||||
const parts = label.split("/");
|
||||
return parts[parts.length - 1]?.trim() || label;
|
||||
}
|
||||
|
||||
function fuelMeter(account: ProviderAccountSnapshot): ProviderAccountMeter | undefined {
|
||||
return [...account.meters]
|
||||
.filter((meter) => providerAccountMeterRemainingRatio(meter) !== undefined)
|
||||
.sort((a, b) => (providerAccountMeterRemainingRatio(a) ?? 1) - (providerAccountMeterRemainingRatio(b) ?? 1))[0] ?? primaryProviderAccountMeter(account);
|
||||
}
|
||||
|
||||
function safeProviderName(value: string, translate: (value: string) => string): string {
|
||||
return value.trim() || translate("Provider");
|
||||
}
|
||||
|
||||
function uniqueCount(values: string[]): number {
|
||||
return new Set(values.filter(Boolean)).size;
|
||||
}
|
||||
|
||||
function rangeLabel(range: UsageStatsRange, translate: (value: string) => string): string {
|
||||
if (range === "today") {
|
||||
return translate("Today");
|
||||
}
|
||||
return translate(range);
|
||||
}
|
||||
|
||||
function calendarColor(intensity: TokenActivityCell["intensity"], inRange: boolean): string {
|
||||
if (!inRange) return "rgba(225,29,72,.08)";
|
||||
if (intensity === 0) return "rgba(225,29,72,.13)";
|
||||
if (intensity === 1) return "rgba(225,29,72,.30)";
|
||||
if (intensity === 2) return "rgba(225,29,72,.50)";
|
||||
if (intensity === 3) return "rgba(225,29,72,.72)";
|
||||
return "rgba(225,29,72,.94)";
|
||||
}
|
||||
|
||||
async function saveElementAsPng(element: HTMLElement, fileName: string, options: ShareCardPngExportOptions = {}): Promise<void> {
|
||||
if (window.ccr?.captureElementPng) {
|
||||
await nativeCaptureElementAsPng(element, fileName, options);
|
||||
return;
|
||||
}
|
||||
await browserExportElementAsPng(element, fileName);
|
||||
}
|
||||
|
||||
async function nativeCaptureElementAsPng(element: HTMLElement, fileName: string, options: ShareCardPngExportOptions): Promise<void> {
|
||||
element.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
await nextFrame();
|
||||
const rect = element.getBoundingClientRect();
|
||||
const result = await window.ccr?.captureElementPng?.({
|
||||
borderRadius: exportBorderRadius(element),
|
||||
exportId: options.exportId,
|
||||
fileName,
|
||||
output: options.output,
|
||||
rect: {
|
||||
height: rect.height,
|
||||
width: rect.width,
|
||||
x: rect.x,
|
||||
y: rect.y
|
||||
}
|
||||
});
|
||||
if (!result || result.canceled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function exportBorderRadius(element: HTMLElement): number | undefined {
|
||||
const target = element.firstElementChild instanceof HTMLElement ? element.firstElementChild : element;
|
||||
const styles = window.getComputedStyle(target);
|
||||
const radii = [
|
||||
styles.borderTopLeftRadius,
|
||||
styles.borderTopRightRadius,
|
||||
styles.borderBottomRightRadius,
|
||||
styles.borderBottomLeftRadius
|
||||
].map((value) => Number.parseFloat(value)).filter(Number.isFinite);
|
||||
const radius = Math.max(0, ...radii);
|
||||
return radius > 0 ? radius : undefined;
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function browserExportElementAsPng(element: HTMLElement, fileName: string): Promise<void> {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const width = Math.ceil(rect.width);
|
||||
const height = Math.ceil(rect.height);
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error("Cannot export an empty element.");
|
||||
}
|
||||
|
||||
const clone = element.cloneNode(true) as HTMLElement;
|
||||
clone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
|
||||
clone.style.width = `${width}px`;
|
||||
clone.style.height = `${height}px`;
|
||||
inlineComputedStyles(element, clone);
|
||||
|
||||
const serialized = new XMLSerializer().serializeToString(clone);
|
||||
const svg = [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
|
||||
`<foreignObject width="100%" height="100%">${serialized}</foreignObject>`,
|
||||
"</svg>"
|
||||
].join("");
|
||||
const svgUrl = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" }));
|
||||
|
||||
try {
|
||||
const image = await loadImage(svgUrl);
|
||||
const scale = 3;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error("Canvas rendering is unavailable.");
|
||||
}
|
||||
context.scale(scale, scale);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const pngBlob = await canvasToBlob(canvas);
|
||||
const pngUrl = URL.createObjectURL(pngBlob);
|
||||
try {
|
||||
const link = document.createElement("a");
|
||||
link.href = pngUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
} finally {
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
}
|
||||
} finally {
|
||||
URL.revokeObjectURL(svgUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function inlineComputedStyles(source: Element, target: Element): void {
|
||||
if (target instanceof HTMLElement || target instanceof SVGElement) {
|
||||
const computed = window.getComputedStyle(source);
|
||||
for (const property of Array.from(computed)) {
|
||||
target.style.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property));
|
||||
}
|
||||
}
|
||||
|
||||
const sourceChildren = Array.from(source.children);
|
||||
const targetChildren = Array.from(target.children);
|
||||
sourceChildren.forEach((child, index) => {
|
||||
const targetChild = targetChildren[index];
|
||||
if (targetChild) {
|
||||
inlineComputedStyles(child, targetChild);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("Failed to render export image."));
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error("Failed to encode PNG image."));
|
||||
}
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
|
@ -674,7 +674,7 @@ export function normalizeOverviewWidget(value: unknown): OverviewWidgetConfig |
|
|||
}
|
||||
|
||||
export function normalizeOverviewWidgetType(value: unknown): OverviewWidgetType | undefined {
|
||||
return typeof value === "string" && ["account-balance", "client-analysis", "metric", "model-distribution", "provider-analysis", "system-status", "token-activity", "token-mix", "usage-trend"].includes(value)
|
||||
return typeof value === "string" && ["account-balance", "client-analysis", "metric", "model-distribution", "provider-analysis", "share-fuel-cockpit", "share-model-leaderboard", "share-route-map", "share-spend-receipt", "share-token-calendar", "share-usage-wrapped", "system-status", "token-activity", "token-mix", "usage-trend"].includes(value)
|
||||
? value as OverviewWidgetType
|
||||
: undefined;
|
||||
}
|
||||
|
|
@ -754,6 +754,11 @@ export function overviewWidgetVariantOptions(type: OverviewWidgetType): Array<{
|
|||
{ label: "Compact", value: "compact" }
|
||||
];
|
||||
}
|
||||
if (isShareOverviewWidgetType(type)) {
|
||||
return [
|
||||
{ label: "Card", value: "card" }
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: "Table", value: "table" },
|
||||
{ label: "Compact", value: "compact" }
|
||||
|
|
@ -775,6 +780,7 @@ export function defaultOverviewWidgetSize(type: OverviewWidgetType): OverviewWid
|
|||
if (type === "client-analysis" || type === "provider-analysis") return "2:2";
|
||||
if (type === "usage-trend") return "3:2";
|
||||
if (type === "system-status") return "4:1";
|
||||
if (isShareOverviewWidgetType(type)) return "1:4";
|
||||
return "4:2";
|
||||
}
|
||||
|
||||
|
|
@ -786,6 +792,7 @@ export function defaultOverviewWidgetVariant(type: OverviewWidgetType): Overview
|
|||
if (type === "token-activity") return "heatmap";
|
||||
if (type === "usage-trend") return "composed";
|
||||
if (type === "system-status") return "timeline";
|
||||
if (isShareOverviewWidgetType(type)) return "card";
|
||||
return "table";
|
||||
}
|
||||
|
||||
|
|
@ -795,12 +802,24 @@ export function constrainOverviewWidgetSize(
|
|||
variant: OverviewWidgetVariant,
|
||||
accountProvider?: string
|
||||
): OverviewWidgetSize {
|
||||
if (isShareOverviewWidgetType(type)) {
|
||||
return overviewWidgetSizeAtLeast(size, 1, 4);
|
||||
}
|
||||
if (type !== "account-balance" || variant !== "compact" || accountProvider?.trim()) {
|
||||
return size;
|
||||
}
|
||||
return overviewWidgetSizeAtLeast(size, 2, 2);
|
||||
}
|
||||
|
||||
function isShareOverviewWidgetType(type: OverviewWidgetType): boolean {
|
||||
return type === "share-fuel-cockpit" ||
|
||||
type === "share-model-leaderboard" ||
|
||||
type === "share-route-map" ||
|
||||
type === "share-spend-receipt" ||
|
||||
type === "share-token-calendar" ||
|
||||
type === "share-usage-wrapped";
|
||||
}
|
||||
|
||||
function overviewWidgetSizeAtLeast(size: OverviewWidgetSize, minWidth: 1 | 2 | 3 | 4, minHeight: 1 | 2 | 3 | 4): OverviewWidgetSize {
|
||||
const [widthText, heightText] = size.split(":");
|
||||
const width = overviewWidgetDimensionAtLeast(widthText, minWidth);
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Bar": "Bar",
|
||||
"Bars": "Bars",
|
||||
"Breakdown component": "Breakdown component",
|
||||
"Card": "Card",
|
||||
"Cards": "Cards",
|
||||
"Change widget type": "Change widget type",
|
||||
"Client Analysis": "Client Analysis",
|
||||
|
|
@ -340,6 +341,27 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Widget": "Widget",
|
||||
"Widget size": "Widget size",
|
||||
"Wide": "Wide",
|
||||
"Share cards": "Share cards",
|
||||
"Share card": "Share card",
|
||||
"Save social-ready cards as PNG images": "Save social-ready cards as PNG images",
|
||||
"Social media PNG cards": "Social media PNG cards",
|
||||
"PNG export": "PNG export",
|
||||
"Save image": "Save image",
|
||||
"Saving": "Saving",
|
||||
"Export failed": "Export failed",
|
||||
"AI Usage Wrapped": "AI Usage Wrapped",
|
||||
"CCR Route Map": "CCR Route Map",
|
||||
"Model Leaderboard": "Model Leaderboard",
|
||||
"AI Fuel Cockpit": "AI Fuel Cockpit",
|
||||
"Token Calendar Poster": "Token Calendar Poster",
|
||||
"Spend Receipt": "Spend Receipt",
|
||||
"tokens routed through CCR": "tokens routed through CCR",
|
||||
"Top model": "Top model",
|
||||
"Top provider": "Top provider",
|
||||
"No provider activity": "No provider activity",
|
||||
"Peak day": "Peak day",
|
||||
"Generated by CCR": "Generated by CCR",
|
||||
"Range": "Range",
|
||||
"Virtual model": "Fusion",
|
||||
"Virtual Models": "Fusion",
|
||||
"Add Virtual Model": "Add Fusion",
|
||||
|
|
@ -967,6 +989,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Bar": "柱状图",
|
||||
"Bars": "横条",
|
||||
"Breakdown component": "构成组件",
|
||||
"Card": "卡片",
|
||||
"Cards": "卡片",
|
||||
"Change widget type": "切换组件类型",
|
||||
"Client or provider": "客户端或供应商",
|
||||
|
|
@ -1034,6 +1057,27 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Widget": "组件",
|
||||
"Widget size": "组件大小",
|
||||
"Wide": "宽",
|
||||
"Share cards": "分享卡片",
|
||||
"Share card": "分享卡片",
|
||||
"Save social-ready cards as PNG images": "将适合社交媒体发布的卡片保存为 PNG 图片",
|
||||
"Social media PNG cards": "适合社交媒体发布的 PNG 卡片",
|
||||
"PNG export": "PNG 导出",
|
||||
"Save image": "保存图片",
|
||||
"Saving": "正在保存",
|
||||
"Export failed": "导出失败",
|
||||
"AI Usage Wrapped": "AI 用量年报",
|
||||
"CCR Route Map": "CCR 路由图",
|
||||
"Model Leaderboard": "模型排行榜",
|
||||
"AI Fuel Cockpit": "AI 燃料仪表",
|
||||
"Token Calendar Poster": "Token 日历海报",
|
||||
"Spend Receipt": "消费小票",
|
||||
"tokens routed through CCR": "通过 CCR 路由的令牌",
|
||||
"Top model": "最高频模型",
|
||||
"Top provider": "最高频供应商",
|
||||
"No provider activity": "暂无供应商活动",
|
||||
"Peak day": "峰值日期",
|
||||
"Generated by CCR": "由 CCR 生成",
|
||||
"Range": "范围",
|
||||
"W": "三",
|
||||
"Yes": "是",
|
||||
"Set as default provider": "设为默认供应商",
|
||||
|
|
@ -1123,6 +1167,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Base model mode": "基础模型模式",
|
||||
"Client tools": "客户端工具",
|
||||
"Client-visible": "客户端可见",
|
||||
"Clients": "客户端",
|
||||
"Command": "命令",
|
||||
"Command is required.": "命令不能为空。",
|
||||
"Decorate only": "仅装饰",
|
||||
|
|
|
|||
6
src/renderer/types/electron.d.ts
vendored
6
src/renderer/types/electron.d.ts
vendored
|
|
@ -6,8 +6,12 @@ import type {
|
|||
AgentAnalysisTracePayloadFullResult,
|
||||
AgentAnalysisTracePayloadRequest,
|
||||
AppConfig,
|
||||
AppCaptureElementPngRequest,
|
||||
AppCaptureElementPngResult,
|
||||
AppDataExportResult,
|
||||
AppInfo,
|
||||
AppImageExportTargetRequest,
|
||||
AppImageExportTargetResult,
|
||||
AppSaveConfigOptions,
|
||||
AppUpdateStatus,
|
||||
ApiKeyConfig,
|
||||
|
|
@ -73,6 +77,7 @@ declare global {
|
|||
applyClaudeAppGateway: (config?: AppConfig) => Promise<ClaudeAppGatewayApplyResult>;
|
||||
applyProfile: () => Promise<ProfileApplyResult>;
|
||||
cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => Promise<BotGatewayQrLoginCancelResult>;
|
||||
captureElementPng?: (request: AppCaptureElementPngRequest) => Promise<AppCaptureElementPngResult>;
|
||||
checkProviderConnectivity: (request: GatewayProviderConnectivityCheckRequest) => Promise<GatewayProviderConnectivityCheckReport>;
|
||||
closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => Promise<BotGatewayQrWindowCloseResult>;
|
||||
clearProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
|
||||
|
|
@ -108,6 +113,7 @@ declare global {
|
|||
openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => Promise<BotGatewayQrWindowOpenResult>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
openProfile: (request: ProfileOpenRequest) => Promise<ProfileOpenResult>;
|
||||
prepareImageExportTarget?: (request: AppImageExportTargetRequest) => Promise<AppImageExportTargetResult>;
|
||||
probeProviderCandidates: (request: GatewayProviderProbeCandidatesRequest) => Promise<GatewayProviderProbeCandidateResult | undefined>;
|
||||
probeProvider: (request: GatewayProviderProbeRequest) => Promise<GatewayProviderProbeResult>;
|
||||
quitApp: () => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,37 @@ export type AppDataExportResult = {
|
|||
file?: string;
|
||||
};
|
||||
|
||||
export type AppCaptureElementPngRequest = {
|
||||
borderRadius?: number;
|
||||
exportId?: string;
|
||||
fileName: string;
|
||||
output?: {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
rect: {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AppCaptureElementPngResult = {
|
||||
canceled: boolean;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
export type AppImageExportTargetRequest = {
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
export type AppImageExportTargetResult = {
|
||||
canceled: boolean;
|
||||
exportId?: string;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
export type AppUpdateState =
|
||||
| "idle"
|
||||
| "checking"
|
||||
|
|
@ -851,6 +882,12 @@ export type OverviewWidgetType =
|
|||
| "metric"
|
||||
| "model-distribution"
|
||||
| "provider-analysis"
|
||||
| "share-fuel-cockpit"
|
||||
| "share-model-leaderboard"
|
||||
| "share-route-map"
|
||||
| "share-spend-receipt"
|
||||
| "share-token-calendar"
|
||||
| "share-usage-wrapped"
|
||||
| "system-status"
|
||||
| "token-activity"
|
||||
| "token-mix"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export const IPC_CHANNELS = {
|
||||
appBeforeQuit: "ccr:app:before-quit",
|
||||
appCaptureElementPng: "ccr:app:capture-element-png",
|
||||
appCloseTray: "ccr:app:close-tray",
|
||||
appDetectProviderIcon: "ccr:app:detect-provider-icon",
|
||||
appExportData: "ccr:app:export-data",
|
||||
|
|
@ -46,6 +47,7 @@ export const IPC_CHANNELS = {
|
|||
appProbeProviderCandidates: "ccr:app:probe-provider-candidates",
|
||||
appProviderDeepLink: "ccr:app:provider-deep-link",
|
||||
appGetPluginMarketplace: "ccr:app:get-plugin-marketplace",
|
||||
appPrepareImageExportTarget: "ccr:app:prepare-image-export-target",
|
||||
appQuit: "ccr:app:quit",
|
||||
appRevealProxyCertificate: "ccr:app:reveal-proxy-certificate",
|
||||
appRestartProxy: "ccr:app:restart-proxy",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ test("OverviewView renders every overview widget type", () => {
|
|||
{ enabled: true, id: "token-mix", size: "2:2", type: "token-mix", variant: "stacked" },
|
||||
{ enabled: true, id: "models", size: "2:2", type: "model-distribution", variant: "donut" },
|
||||
{ enabled: true, id: "clients", size: "4:2", type: "client-analysis", variant: "table" },
|
||||
{ enabled: true, id: "providers", size: "4:2", type: "provider-analysis", variant: "table" }
|
||||
{ enabled: true, id: "providers", size: "4:2", type: "provider-analysis", variant: "table" },
|
||||
{ enabled: true, id: "share-usage", size: "1:4", type: "share-usage-wrapped", variant: "card" },
|
||||
{ enabled: true, id: "share-routes", size: "1:4", type: "share-route-map", variant: "card" },
|
||||
{ enabled: true, id: "share-models", size: "1:4", type: "share-model-leaderboard", variant: "card" },
|
||||
{ enabled: true, id: "share-fuel", size: "1:4", type: "share-fuel-cockpit", variant: "card" },
|
||||
{ enabled: true, id: "share-calendar", size: "1:4", type: "share-token-calendar", variant: "card" },
|
||||
{ enabled: true, id: "share-receipt", size: "1:4", type: "share-spend-receipt", variant: "card" }
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
|
|
@ -51,6 +57,13 @@ test("OverviewView renders every overview widget type", () => {
|
|||
assert.match(html, /Provider Analysis/);
|
||||
assert.match(html, /claude-code/);
|
||||
assert.match(html, /openai/);
|
||||
assert.match(html, /Save image/);
|
||||
assert.match(html, /AI Usage Wrapped/);
|
||||
assert.match(html, /CCR Route Map/);
|
||||
assert.match(html, /Model Leaderboard/);
|
||||
assert.match(html, /AI Fuel Cockpit/);
|
||||
assert.match(html, /Token Calendar Poster/);
|
||||
assert.match(html, /Spend Receipt/);
|
||||
});
|
||||
|
||||
test("OverviewView renders the empty widget layout state", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue