style(design): apply Minimal Dark Mode design system\n\n- Switched to complete Dark Mode (OLED-friendly)\n- Established standard color palette matching modern technical aesthetics\n- Changed font to Plus Jakarta Sans for increased legibility\n- Enforced dark mode as default in App.tsx\n- Fixed several React cascading render (setState in useEffect) warnings
This commit is contained in:
1 parent
b80531f8a8
commit
bac7b5591e
738 files changed
+138513
-138123
No files matched your search
+483
-483
@@ -1,483 +1,483 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getAccountsFilePath, getBackupsDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
Account,
|
||||
AccountBackupData,
|
||||
DeviceProfile,
|
||||
DeviceProfilesSnapshot,
|
||||
DeviceProfileVersion,
|
||||
} from '../../types/account';
|
||||
import {
|
||||
backupAccount as dbBackup,
|
||||
restoreAccount as dbRestore,
|
||||
getCurrentAccountInfo,
|
||||
} from '../database/handler';
|
||||
import {
|
||||
applyDeviceProfile,
|
||||
ensureGlobalOriginalFromCurrentStorage,
|
||||
generateDeviceProfile,
|
||||
isIdentityProfileApplyEnabled,
|
||||
loadGlobalOriginalProfile,
|
||||
readCurrentDeviceProfile,
|
||||
saveGlobalOriginalProfile,
|
||||
getStorageDirectoryPath,
|
||||
} from '../device/handler';
|
||||
import { runWithSwitchGuard } from '../switchGuard';
|
||||
import { executeSwitchFlow } from '../switchFlow';
|
||||
import { shell } from 'electron';
|
||||
|
||||
type AccountIndex = Record<string, Account>;
|
||||
const SWITCH_EXIT_TIMEOUT_MS = 10000;
|
||||
|
||||
function getDeviceHistory(account: Account): DeviceProfileVersion[] {
|
||||
if (!account.deviceHistory) {
|
||||
account.deviceHistory = [];
|
||||
}
|
||||
return account.deviceHistory;
|
||||
}
|
||||
|
||||
function bindDeviceProfileToAccount(
|
||||
account: Account,
|
||||
profile: DeviceProfile,
|
||||
label: string,
|
||||
addHistory: boolean,
|
||||
): void {
|
||||
account.deviceProfile = profile;
|
||||
if (!addHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = getDeviceHistory(account);
|
||||
for (const version of history) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
|
||||
history.push({
|
||||
id: uuidv4(),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
label,
|
||||
profile,
|
||||
isCurrent: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the accounts index from the file system.
|
||||
* @returns {AccountIndex} The accounts index.
|
||||
*/
|
||||
function loadAccountsIndex(): AccountIndex {
|
||||
const filePath = getAccountsFilePath();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load accounts index', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the accounts index to the file system.
|
||||
* @param accounts {AccountIndex} The accounts index to save.
|
||||
* @throws {Error} If the accounts index cannot be saved.
|
||||
*/
|
||||
function saveAccountsIndex(accounts: AccountIndex): void {
|
||||
const filePath = getAccountsFilePath();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(accounts, null, 2));
|
||||
} catch (error) {
|
||||
logger.error('Failed to save accounts index', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the accounts data.
|
||||
* @returns {Account[]} The list of accounts.
|
||||
* @throws {Error} If the accounts index cannot be loaded.
|
||||
*/
|
||||
export async function listAccountsData(): Promise<Account[]> {
|
||||
const accountsObj = loadAccountsIndex();
|
||||
const accountsList = Object.values(accountsObj);
|
||||
// NOTE: Sort by last_used descending
|
||||
accountsList.sort((a, b) => {
|
||||
const aTime = a.last_used || '';
|
||||
const bTime = b.last_used || '';
|
||||
return bTime.localeCompare(aTime);
|
||||
});
|
||||
return accountsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an account snapshot.
|
||||
* @returns {Account} The added account.
|
||||
* @throws {Error} If the account cannot be added.
|
||||
*/
|
||||
export async function addAccountSnapshot(): Promise<Account> {
|
||||
logger.info('Adding account snapshot...');
|
||||
|
||||
// NOTE Get current account info from DB
|
||||
const info = getCurrentAccountInfo();
|
||||
if (!info.isAuthenticated) {
|
||||
const errorMsg =
|
||||
'No authenticated account found. Please ensure Gemini Nexus is running and you are logged in.';
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// NOTE Find existing account by email
|
||||
let existingId: string | null = null;
|
||||
for (const [id, acc] of Object.entries(accounts)) {
|
||||
if (acc.email === info.email) {
|
||||
existingId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let account: Account;
|
||||
let backupPath: string;
|
||||
|
||||
if (existingId) {
|
||||
// NOTE Update existing account
|
||||
account = accounts[existingId];
|
||||
|
||||
// NOTE Preserve custom name: only update if we have a name from DB AND it's not the default email prefix
|
||||
// NOTE if not name or name == email.split("@")[0]: name = existing_account.get("name", name)
|
||||
const defaultName = info.email.split('@')[0];
|
||||
if (!info.name || info.name === defaultName) {
|
||||
// NOTE Keep the existing custom name
|
||||
// (account.name is already set, no change needed)
|
||||
} else {
|
||||
// NOTE We have a non-default name from DB, use it
|
||||
account.name = info.name;
|
||||
}
|
||||
|
||||
account.last_used = now;
|
||||
|
||||
// NOTE Use existing backup path if available, otherwise generate new one
|
||||
backupPath = account.backup_file || path.join(getBackupsDir(), `${account.id}.json`);
|
||||
|
||||
logger.info(`Updating existing account: ${info.email}`);
|
||||
} else {
|
||||
const accountId = uuidv4();
|
||||
|
||||
// NOTE Generate name with edge case handling
|
||||
let accountName: string;
|
||||
if (info.name) {
|
||||
accountName = info.name;
|
||||
} else if (info.email && info.email !== 'Unknown') {
|
||||
accountName = info.email.split('@')[0];
|
||||
} else {
|
||||
// Edge case: email is "Unknown" or invalid
|
||||
accountName = `Account_${Date.now()}`;
|
||||
}
|
||||
|
||||
backupPath = path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
account = {
|
||||
id: accountId,
|
||||
name: accountName,
|
||||
email: info.email,
|
||||
backup_file: backupPath,
|
||||
deviceHistory: [],
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
};
|
||||
accounts[accountId] = account;
|
||||
logger.info(`Creating new account: ${info.email}`);
|
||||
}
|
||||
|
||||
// NOTE Backup data from DB
|
||||
const backupData = dbBackup(account);
|
||||
|
||||
// NOTE Save backup file
|
||||
const backupsDir = getBackupsDir();
|
||||
if (!fs.existsSync(backupsDir)) {
|
||||
fs.mkdirSync(backupsDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(backupPath, JSON.stringify(backupData, null, 2));
|
||||
|
||||
// NOTE Update backup_file in account object and save
|
||||
account.backup_file = backupPath;
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to an account.
|
||||
* @param accountId {string} The ID of the account to switch to.
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function switchAccount(accountId: string): Promise<void> {
|
||||
await runWithSwitchGuard('local-account-switch', async () => {
|
||||
logger.info(`Switching to account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Get backup file path from account data
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`);
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
if (!account.deviceProfile) {
|
||||
const generated = generateDeviceProfile();
|
||||
saveGlobalOriginalProfile(generated);
|
||||
bindDeviceProfileToAccount(account, generated, 'auto_generated', true);
|
||||
}
|
||||
|
||||
await executeSwitchFlow({
|
||||
scope: 'local',
|
||||
targetProfile: account.deviceProfile || null,
|
||||
applyFingerprint: isIdentityProfileApplyEnabled(),
|
||||
processExitTimeoutMs: SWITCH_EXIT_TIMEOUT_MS,
|
||||
performSwitch: async () => {
|
||||
// NOTE Load backup file
|
||||
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
||||
const backupData: AccountBackupData = JSON.parse(backupContent);
|
||||
|
||||
// NOTE Restore data to DB
|
||||
dbRestore(backupData);
|
||||
|
||||
// NOTE Update last used
|
||||
account.last_used = new Date().toISOString();
|
||||
saveAccountsIndex(accounts);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewGenerateIdentityProfile(): Promise<DeviceProfile> {
|
||||
return generateDeviceProfile();
|
||||
}
|
||||
|
||||
export async function getIdentityProfiles(accountId: string): Promise<DeviceProfilesSnapshot> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let currentStorage: DeviceProfile | undefined;
|
||||
try {
|
||||
currentStorage = readCurrentDeviceProfile();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read current storage device profile', error);
|
||||
}
|
||||
|
||||
return {
|
||||
currentStorage,
|
||||
boundProfile: account.deviceProfile,
|
||||
history: account.deviceHistory || [],
|
||||
baseline: loadGlobalOriginalProfile() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function bindIdentityProfile(
|
||||
accountId: string,
|
||||
mode: 'capture' | 'generate',
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let profile: DeviceProfile;
|
||||
if (mode === 'capture') {
|
||||
profile = readCurrentDeviceProfile();
|
||||
} else {
|
||||
profile = generateDeviceProfile();
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
saveGlobalOriginalProfile(profile);
|
||||
applyDeviceProfile(profile);
|
||||
bindDeviceProfileToAccount(account, profile, mode, true);
|
||||
saveAccountsIndex(accounts);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function bindIdentityProfileWithPayload(
|
||||
accountId: string,
|
||||
profile: DeviceProfile,
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
saveGlobalOriginalProfile(profile);
|
||||
applyDeviceProfile(profile);
|
||||
bindDeviceProfileToAccount(account, profile, 'generated', true);
|
||||
saveAccountsIndex(accounts);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function applyBoundIdentityProfile(accountId: string): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
if (!account.deviceProfile) {
|
||||
throw new Error('Account has no bound device profile');
|
||||
}
|
||||
|
||||
applyDeviceProfile(account.deviceProfile);
|
||||
account.last_used = new Date().toISOString();
|
||||
saveAccountsIndex(accounts);
|
||||
return account.deviceProfile;
|
||||
}
|
||||
|
||||
export async function restoreIdentityProfileRevision(
|
||||
accountId: string,
|
||||
versionId: string,
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let targetProfile: DeviceProfile | null = null;
|
||||
if (versionId === 'baseline') {
|
||||
targetProfile = loadGlobalOriginalProfile();
|
||||
if (!targetProfile) {
|
||||
throw new Error('Global original profile not found');
|
||||
}
|
||||
for (const version of getDeviceHistory(account)) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
} else if (versionId === 'current') {
|
||||
targetProfile = account.deviceProfile || null;
|
||||
if (!targetProfile) {
|
||||
throw new Error('No currently bound profile');
|
||||
}
|
||||
} else {
|
||||
const history = getDeviceHistory(account);
|
||||
const targetVersion = history.find((version) => version.id === versionId);
|
||||
if (!targetVersion) {
|
||||
throw new Error('Device profile version not found');
|
||||
}
|
||||
targetProfile = targetVersion.profile;
|
||||
for (const version of history) {
|
||||
version.isCurrent = version.id === versionId;
|
||||
}
|
||||
}
|
||||
|
||||
applyDeviceProfile(targetProfile);
|
||||
account.deviceProfile = targetProfile;
|
||||
saveAccountsIndex(accounts);
|
||||
return targetProfile;
|
||||
}
|
||||
|
||||
export async function deleteIdentityProfileRevision(
|
||||
accountId: string,
|
||||
versionId: string,
|
||||
): Promise<void> {
|
||||
if (versionId === 'baseline') {
|
||||
throw new Error('Original profile cannot be deleted');
|
||||
}
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
const history = getDeviceHistory(account);
|
||||
if (history.some((version) => version.id === versionId && version.isCurrent)) {
|
||||
throw new Error('Currently bound profile cannot be deleted');
|
||||
}
|
||||
|
||||
const before = history.length;
|
||||
account.deviceHistory = history.filter((version) => version.id !== versionId);
|
||||
if (account.deviceHistory.length === before) {
|
||||
throw new Error('Historical device profile not found');
|
||||
}
|
||||
|
||||
saveAccountsIndex(accounts);
|
||||
}
|
||||
|
||||
export async function restoreBaselineProfile(accountId: string): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
const baseline = loadGlobalOriginalProfile();
|
||||
if (!baseline) {
|
||||
throw new Error('Global original profile not found');
|
||||
}
|
||||
|
||||
account.deviceProfile = baseline;
|
||||
for (const version of getDeviceHistory(account)) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
return baseline;
|
||||
}
|
||||
|
||||
export async function openIdentityStorageFolder(): Promise<void> {
|
||||
const directory = getStorageDirectoryPath();
|
||||
const result = await shell.openPath(directory);
|
||||
if (result) {
|
||||
throw new Error(`Failed to open device folder: ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an account.
|
||||
* @param accountId {string} The ID of the account to delete.
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function deleteAccount(accountId: string): Promise<void> {
|
||||
logger.info(`Deleting account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Remove backup file using stored path
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (fs.existsSync(backupPath)) {
|
||||
try {
|
||||
fs.unlinkSync(backupPath);
|
||||
logger.info(`Backup file deleted: ${backupPath}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete backup file: ${backupPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Remove from index
|
||||
delete accounts[accountId];
|
||||
saveAccountsIndex(accounts);
|
||||
}
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getAccountsFilePath, getBackupsDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
Account,
|
||||
AccountBackupData,
|
||||
DeviceProfile,
|
||||
DeviceProfilesSnapshot,
|
||||
DeviceProfileVersion,
|
||||
} from '../../types/account';
|
||||
import {
|
||||
backupAccount as dbBackup,
|
||||
restoreAccount as dbRestore,
|
||||
getCurrentAccountInfo,
|
||||
} from '../database/handler';
|
||||
import {
|
||||
applyDeviceProfile,
|
||||
ensureGlobalOriginalFromCurrentStorage,
|
||||
generateDeviceProfile,
|
||||
isIdentityProfileApplyEnabled,
|
||||
loadGlobalOriginalProfile,
|
||||
readCurrentDeviceProfile,
|
||||
saveGlobalOriginalProfile,
|
||||
getStorageDirectoryPath,
|
||||
} from '../device/handler';
|
||||
import { runWithSwitchGuard } from '../switchGuard';
|
||||
import { executeSwitchFlow } from '../switchFlow';
|
||||
import { shell } from 'electron';
|
||||
|
||||
type AccountIndex = Record<string, Account>;
|
||||
const SWITCH_EXIT_TIMEOUT_MS = 10000;
|
||||
|
||||
function getDeviceHistory(account: Account): DeviceProfileVersion[] {
|
||||
if (!account.deviceHistory) {
|
||||
account.deviceHistory = [];
|
||||
}
|
||||
return account.deviceHistory;
|
||||
}
|
||||
|
||||
function bindDeviceProfileToAccount(
|
||||
account: Account,
|
||||
profile: DeviceProfile,
|
||||
label: string,
|
||||
addHistory: boolean,
|
||||
): void {
|
||||
account.deviceProfile = profile;
|
||||
if (!addHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const history = getDeviceHistory(account);
|
||||
for (const version of history) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
|
||||
history.push({
|
||||
id: uuidv4(),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
label,
|
||||
profile,
|
||||
isCurrent: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the accounts index from the file system.
|
||||
* @returns {AccountIndex} The accounts index.
|
||||
*/
|
||||
function loadAccountsIndex(): AccountIndex {
|
||||
const filePath = getAccountsFilePath();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load accounts index', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the accounts index to the file system.
|
||||
* @param accounts {AccountIndex} The accounts index to save.
|
||||
* @throws {Error} If the accounts index cannot be saved.
|
||||
*/
|
||||
function saveAccountsIndex(accounts: AccountIndex): void {
|
||||
const filePath = getAccountsFilePath();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(accounts, null, 2));
|
||||
} catch (error) {
|
||||
logger.error('Failed to save accounts index', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the accounts data.
|
||||
* @returns {Account[]} The list of accounts.
|
||||
* @throws {Error} If the accounts index cannot be loaded.
|
||||
*/
|
||||
export async function listAccountsData(): Promise<Account[]> {
|
||||
const accountsObj = loadAccountsIndex();
|
||||
const accountsList = Object.values(accountsObj);
|
||||
// NOTE: Sort by last_used descending
|
||||
accountsList.sort((a, b) => {
|
||||
const aTime = a.last_used || '';
|
||||
const bTime = b.last_used || '';
|
||||
return bTime.localeCompare(aTime);
|
||||
});
|
||||
return accountsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an account snapshot.
|
||||
* @returns {Account} The added account.
|
||||
* @throws {Error} If the account cannot be added.
|
||||
*/
|
||||
export async function addAccountSnapshot(): Promise<Account> {
|
||||
logger.info('Adding account snapshot...');
|
||||
|
||||
// NOTE Get current account info from DB
|
||||
const info = getCurrentAccountInfo();
|
||||
if (!info.isAuthenticated) {
|
||||
const errorMsg =
|
||||
'No authenticated account found. Please ensure Gemini Nexus is running and you are logged in.';
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// NOTE Find existing account by email
|
||||
let existingId: string | null = null;
|
||||
for (const [id, acc] of Object.entries(accounts)) {
|
||||
if (acc.email === info.email) {
|
||||
existingId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let account: Account;
|
||||
let backupPath: string;
|
||||
|
||||
if (existingId) {
|
||||
// NOTE Update existing account
|
||||
account = accounts[existingId];
|
||||
|
||||
// NOTE Preserve custom name: only update if we have a name from DB AND it's not the default email prefix
|
||||
// NOTE if not name or name == email.split("@")[0]: name = existing_account.get("name", name)
|
||||
const defaultName = info.email.split('@')[0];
|
||||
if (!info.name || info.name === defaultName) {
|
||||
// NOTE Keep the existing custom name
|
||||
// (account.name is already set, no change needed)
|
||||
} else {
|
||||
// NOTE We have a non-default name from DB, use it
|
||||
account.name = info.name;
|
||||
}
|
||||
|
||||
account.last_used = now;
|
||||
|
||||
// NOTE Use existing backup path if available, otherwise generate new one
|
||||
backupPath = account.backup_file || path.join(getBackupsDir(), `${account.id}.json`);
|
||||
|
||||
logger.info(`Updating existing account: ${info.email}`);
|
||||
} else {
|
||||
const accountId = uuidv4();
|
||||
|
||||
// NOTE Generate name with edge case handling
|
||||
let accountName: string;
|
||||
if (info.name) {
|
||||
accountName = info.name;
|
||||
} else if (info.email && info.email !== 'Unknown') {
|
||||
accountName = info.email.split('@')[0];
|
||||
} else {
|
||||
// Edge case: email is "Unknown" or invalid
|
||||
accountName = `Account_${Date.now()}`;
|
||||
}
|
||||
|
||||
backupPath = path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
account = {
|
||||
id: accountId,
|
||||
name: accountName,
|
||||
email: info.email,
|
||||
backup_file: backupPath,
|
||||
deviceHistory: [],
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
};
|
||||
accounts[accountId] = account;
|
||||
logger.info(`Creating new account: ${info.email}`);
|
||||
}
|
||||
|
||||
// NOTE Backup data from DB
|
||||
const backupData = dbBackup(account);
|
||||
|
||||
// NOTE Save backup file
|
||||
const backupsDir = getBackupsDir();
|
||||
if (!fs.existsSync(backupsDir)) {
|
||||
fs.mkdirSync(backupsDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(backupPath, JSON.stringify(backupData, null, 2));
|
||||
|
||||
// NOTE Update backup_file in account object and save
|
||||
account.backup_file = backupPath;
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to an account.
|
||||
* @param accountId {string} The ID of the account to switch to.
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function switchAccount(accountId: string): Promise<void> {
|
||||
await runWithSwitchGuard('local-account-switch', async () => {
|
||||
logger.info(`Switching to account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Get backup file path from account data
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`);
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
if (!account.deviceProfile) {
|
||||
const generated = generateDeviceProfile();
|
||||
saveGlobalOriginalProfile(generated);
|
||||
bindDeviceProfileToAccount(account, generated, 'auto_generated', true);
|
||||
}
|
||||
|
||||
await executeSwitchFlow({
|
||||
scope: 'local',
|
||||
targetProfile: account.deviceProfile || null,
|
||||
applyFingerprint: isIdentityProfileApplyEnabled(),
|
||||
processExitTimeoutMs: SWITCH_EXIT_TIMEOUT_MS,
|
||||
performSwitch: async () => {
|
||||
// NOTE Load backup file
|
||||
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
||||
const backupData: AccountBackupData = JSON.parse(backupContent);
|
||||
|
||||
// NOTE Restore data to DB
|
||||
dbRestore(backupData);
|
||||
|
||||
// NOTE Update last used
|
||||
account.last_used = new Date().toISOString();
|
||||
saveAccountsIndex(accounts);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewGenerateIdentityProfile(): Promise<DeviceProfile> {
|
||||
return generateDeviceProfile();
|
||||
}
|
||||
|
||||
export async function getIdentityProfiles(accountId: string): Promise<DeviceProfilesSnapshot> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let currentStorage: DeviceProfile | undefined;
|
||||
try {
|
||||
currentStorage = readCurrentDeviceProfile();
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read current storage device profile', error);
|
||||
}
|
||||
|
||||
return {
|
||||
currentStorage,
|
||||
boundProfile: account.deviceProfile,
|
||||
history: account.deviceHistory || [],
|
||||
baseline: loadGlobalOriginalProfile() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function bindIdentityProfile(
|
||||
accountId: string,
|
||||
mode: 'capture' | 'generate',
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let profile: DeviceProfile;
|
||||
if (mode === 'capture') {
|
||||
profile = readCurrentDeviceProfile();
|
||||
} else {
|
||||
profile = generateDeviceProfile();
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
saveGlobalOriginalProfile(profile);
|
||||
applyDeviceProfile(profile);
|
||||
bindDeviceProfileToAccount(account, profile, mode, true);
|
||||
saveAccountsIndex(accounts);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function bindIdentityProfileWithPayload(
|
||||
accountId: string,
|
||||
profile: DeviceProfile,
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
ensureGlobalOriginalFromCurrentStorage();
|
||||
saveGlobalOriginalProfile(profile);
|
||||
applyDeviceProfile(profile);
|
||||
bindDeviceProfileToAccount(account, profile, 'generated', true);
|
||||
saveAccountsIndex(accounts);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function applyBoundIdentityProfile(accountId: string): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
if (!account.deviceProfile) {
|
||||
throw new Error('Account has no bound device profile');
|
||||
}
|
||||
|
||||
applyDeviceProfile(account.deviceProfile);
|
||||
account.last_used = new Date().toISOString();
|
||||
saveAccountsIndex(accounts);
|
||||
return account.deviceProfile;
|
||||
}
|
||||
|
||||
export async function restoreIdentityProfileRevision(
|
||||
accountId: string,
|
||||
versionId: string,
|
||||
): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
let targetProfile: DeviceProfile | null = null;
|
||||
if (versionId === 'baseline') {
|
||||
targetProfile = loadGlobalOriginalProfile();
|
||||
if (!targetProfile) {
|
||||
throw new Error('Global original profile not found');
|
||||
}
|
||||
for (const version of getDeviceHistory(account)) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
} else if (versionId === 'current') {
|
||||
targetProfile = account.deviceProfile || null;
|
||||
if (!targetProfile) {
|
||||
throw new Error('No currently bound profile');
|
||||
}
|
||||
} else {
|
||||
const history = getDeviceHistory(account);
|
||||
const targetVersion = history.find((version) => version.id === versionId);
|
||||
if (!targetVersion) {
|
||||
throw new Error('Device profile version not found');
|
||||
}
|
||||
targetProfile = targetVersion.profile;
|
||||
for (const version of history) {
|
||||
version.isCurrent = version.id === versionId;
|
||||
}
|
||||
}
|
||||
|
||||
applyDeviceProfile(targetProfile);
|
||||
account.deviceProfile = targetProfile;
|
||||
saveAccountsIndex(accounts);
|
||||
return targetProfile;
|
||||
}
|
||||
|
||||
export async function deleteIdentityProfileRevision(
|
||||
accountId: string,
|
||||
versionId: string,
|
||||
): Promise<void> {
|
||||
if (versionId === 'baseline') {
|
||||
throw new Error('Original profile cannot be deleted');
|
||||
}
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
const history = getDeviceHistory(account);
|
||||
if (history.some((version) => version.id === versionId && version.isCurrent)) {
|
||||
throw new Error('Currently bound profile cannot be deleted');
|
||||
}
|
||||
|
||||
const before = history.length;
|
||||
account.deviceHistory = history.filter((version) => version.id !== versionId);
|
||||
if (account.deviceHistory.length === before) {
|
||||
throw new Error('Historical device profile not found');
|
||||
}
|
||||
|
||||
saveAccountsIndex(accounts);
|
||||
}
|
||||
|
||||
export async function restoreBaselineProfile(accountId: string): Promise<DeviceProfile> {
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
const baseline = loadGlobalOriginalProfile();
|
||||
if (!baseline) {
|
||||
throw new Error('Global original profile not found');
|
||||
}
|
||||
|
||||
account.deviceProfile = baseline;
|
||||
for (const version of getDeviceHistory(account)) {
|
||||
version.isCurrent = false;
|
||||
}
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
return baseline;
|
||||
}
|
||||
|
||||
export async function openIdentityStorageFolder(): Promise<void> {
|
||||
const directory = getStorageDirectoryPath();
|
||||
const result = await shell.openPath(directory);
|
||||
if (result) {
|
||||
throw new Error(`Failed to open device folder: ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an account.
|
||||
* @param accountId {string} The ID of the account to delete.
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function deleteAccount(accountId: string): Promise<void> {
|
||||
logger.info(`Deleting account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Remove backup file using stored path
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (fs.existsSync(backupPath)) {
|
||||
try {
|
||||
fs.unlinkSync(backupPath);
|
||||
logger.info(`Backup file deleted: ${backupPath}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete backup file: ${backupPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Remove from index
|
||||
delete accounts[accountId];
|
||||
saveAccountsIndex(accounts);
|
||||
}
|
||||
+103
-103
@@ -1,103 +1,103 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import {
|
||||
applyBoundIdentityProfile,
|
||||
addAccountSnapshot,
|
||||
bindIdentityProfile,
|
||||
bindIdentityProfileWithPayload,
|
||||
deleteAccount,
|
||||
deleteIdentityProfileRevision,
|
||||
getIdentityProfiles,
|
||||
listAccountsData,
|
||||
previewGenerateIdentityProfile,
|
||||
restoreBaselineProfile,
|
||||
restoreIdentityProfileRevision,
|
||||
switchAccount,
|
||||
openIdentityStorageFolder,
|
||||
} from './handler';
|
||||
import {
|
||||
AccountSchema,
|
||||
DeviceProfileSchema,
|
||||
DeviceProfilesSnapshotSchema,
|
||||
} from '../../types/account';
|
||||
|
||||
export const accountRouter = os.router({
|
||||
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
|
||||
return listAccountsData();
|
||||
}),
|
||||
|
||||
addAccountSnapshot: os.output(AccountSchema).handler(async () => {
|
||||
return addAccountSnapshot();
|
||||
}),
|
||||
|
||||
switchAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await switchAccount(input.accountId);
|
||||
}),
|
||||
|
||||
deleteAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteAccount(input.accountId);
|
||||
}),
|
||||
|
||||
previewGenerateIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
|
||||
return previewGenerateIdentityProfile();
|
||||
}),
|
||||
|
||||
getIdentityProfiles: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfilesSnapshotSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getIdentityProfiles(input.accountId);
|
||||
}),
|
||||
|
||||
bindIdentityProfile: os
|
||||
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindIdentityProfile(input.accountId, input.mode);
|
||||
}),
|
||||
|
||||
bindIdentityProfileWithPayload: os
|
||||
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindIdentityProfileWithPayload(input.accountId, input.profile);
|
||||
}),
|
||||
|
||||
applyBoundIdentityProfile: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return applyBoundIdentityProfile(input.accountId);
|
||||
}),
|
||||
|
||||
restoreIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
deleteIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
restoreBaselineProfile: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreBaselineProfile(input.accountId);
|
||||
}),
|
||||
|
||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||
await openIdentityStorageFolder();
|
||||
}),
|
||||
});
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import {
|
||||
applyBoundIdentityProfile,
|
||||
addAccountSnapshot,
|
||||
bindIdentityProfile,
|
||||
bindIdentityProfileWithPayload,
|
||||
deleteAccount,
|
||||
deleteIdentityProfileRevision,
|
||||
getIdentityProfiles,
|
||||
listAccountsData,
|
||||
previewGenerateIdentityProfile,
|
||||
restoreBaselineProfile,
|
||||
restoreIdentityProfileRevision,
|
||||
switchAccount,
|
||||
openIdentityStorageFolder,
|
||||
} from './handler';
|
||||
import {
|
||||
AccountSchema,
|
||||
DeviceProfileSchema,
|
||||
DeviceProfilesSnapshotSchema,
|
||||
} from '../../types/account';
|
||||
|
||||
export const accountRouter = os.router({
|
||||
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
|
||||
return listAccountsData();
|
||||
}),
|
||||
|
||||
addAccountSnapshot: os.output(AccountSchema).handler(async () => {
|
||||
return addAccountSnapshot();
|
||||
}),
|
||||
|
||||
switchAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await switchAccount(input.accountId);
|
||||
}),
|
||||
|
||||
deleteAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteAccount(input.accountId);
|
||||
}),
|
||||
|
||||
previewGenerateIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
|
||||
return previewGenerateIdentityProfile();
|
||||
}),
|
||||
|
||||
getIdentityProfiles: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfilesSnapshotSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getIdentityProfiles(input.accountId);
|
||||
}),
|
||||
|
||||
bindIdentityProfile: os
|
||||
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindIdentityProfile(input.accountId, input.mode);
|
||||
}),
|
||||
|
||||
bindIdentityProfileWithPayload: os
|
||||
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindIdentityProfileWithPayload(input.accountId, input.profile);
|
||||
}),
|
||||
|
||||
applyBoundIdentityProfile: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return applyBoundIdentityProfile(input.accountId);
|
||||
}),
|
||||
|
||||
restoreIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
deleteIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
restoreBaselineProfile: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreBaselineProfile(input.accountId);
|
||||
}),
|
||||
|
||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||
await openIdentityStorageFolder();
|
||||
}),
|
||||
});
|
||||
+10
-10
@@ -1,10 +1,10 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { app } from 'electron';
|
||||
|
||||
export const currentPlatfom = os.handler(() => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
export const appVersion = os.handler(() => {
|
||||
return app.getVersion();
|
||||
});
|
||||
import { os } from '@orpc/server';
|
||||
import { app } from 'electron';
|
||||
|
||||
export const currentPlatfom = os.handler(() => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
export const appVersion = os.handler(() => {
|
||||
return app.getVersion();
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { appVersion, currentPlatfom } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const app = os.router({
|
||||
currentPlatfom,
|
||||
appVersion,
|
||||
});
|
||||
import { appVersion, currentPlatfom } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const app = os.router({
|
||||
currentPlatfom,
|
||||
appVersion,
|
||||
});
|
||||
+136
-136
@@ -1,136 +1,136 @@
|
||||
import http from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ipcContext } from '../context';
|
||||
import { escapeHtml } from '../../utils/url';
|
||||
|
||||
export class AuthServer {
|
||||
private static server: http.Server | null = null;
|
||||
private static PORT = 8888;
|
||||
|
||||
static async start() {
|
||||
if (this.server) {
|
||||
logger.warn('AuthServer: Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const tryPorts = [8888, 8889, 8890, 8891, 8892];
|
||||
let boundPort: number | null = null;
|
||||
|
||||
for (const port of tryPorts) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const testServer = http.createServer();
|
||||
testServer.once('error', reject);
|
||||
testServer.listen(port, '127.0.0.1', () => {
|
||||
testServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
boundPort = port;
|
||||
break;
|
||||
} catch {
|
||||
logger.debug(`AuthServer: Port ${port} is in use, trying next...`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!boundPort) {
|
||||
logger.error('AuthServer: No available ports found for OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
if (boundPort !== 8888) {
|
||||
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
|
||||
}
|
||||
|
||||
this.PORT = boundPort;
|
||||
|
||||
try {
|
||||
this.server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { Allow: 'GET' });
|
||||
res.end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
const escapedCode = escapeHtml(code);
|
||||
logger.info(
|
||||
`AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`,
|
||||
);
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||
<h1>Login Successful</h1>
|
||||
<p>You can close this window and return to Gemini Nexus.</p>
|
||||
<script>
|
||||
setTimeout(() => window.close(), 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (error) {
|
||||
const escapedError = escapeHtml(error);
|
||||
logger.error(`AuthServer: OAuth error: ${escapedError}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: ${escapedError}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, '127.0.0.1', () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('AuthServer: Failed to create or start server', e);
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getRedirectUri(): string {
|
||||
return `http://localhost:${this.PORT}/oauth-callback`;
|
||||
}
|
||||
|
||||
static stop() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
logger.info('AuthServer: Stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
import http from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ipcContext } from '../context';
|
||||
import { escapeHtml } from '../../utils/url';
|
||||
|
||||
export class AuthServer {
|
||||
private static server: http.Server | null = null;
|
||||
private static PORT = 8888;
|
||||
|
||||
static async start() {
|
||||
if (this.server) {
|
||||
logger.warn('AuthServer: Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const tryPorts = [8888, 8889, 8890, 8891, 8892];
|
||||
let boundPort: number | null = null;
|
||||
|
||||
for (const port of tryPorts) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const testServer = http.createServer();
|
||||
testServer.once('error', reject);
|
||||
testServer.listen(port, '127.0.0.1', () => {
|
||||
testServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
boundPort = port;
|
||||
break;
|
||||
} catch {
|
||||
logger.debug(`AuthServer: Port ${port} is in use, trying next...`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!boundPort) {
|
||||
logger.error('AuthServer: No available ports found for OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
if (boundPort !== 8888) {
|
||||
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
|
||||
}
|
||||
|
||||
this.PORT = boundPort;
|
||||
|
||||
try {
|
||||
this.server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { Allow: 'GET' });
|
||||
res.end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
const escapedCode = escapeHtml(code);
|
||||
logger.info(
|
||||
`AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`,
|
||||
);
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||
<h1>Login Successful</h1>
|
||||
<p>You can close this window and return to Gemini Nexus.</p>
|
||||
<script>
|
||||
setTimeout(() => window.close(), 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (error) {
|
||||
const escapedError = escapeHtml(error);
|
||||
logger.error(`AuthServer: OAuth error: ${escapedError}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: ${escapedError}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, '127.0.0.1', () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('AuthServer: Failed to create or start server', e);
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getRedirectUri(): string {
|
||||
return `http://localhost:${this.PORT}/oauth-callback`;
|
||||
}
|
||||
|
||||
static stop() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
logger.info('AuthServer: Stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
+843
-843
File diff suppressed because it is too large.
Load diff
+305
-305
@@ -1,305 +1,305 @@
|
||||
import { z } from 'zod';
|
||||
import { os, ORPCError } from '@orpc/server';
|
||||
import {
|
||||
addGoogleAccount,
|
||||
bindCloudIdentityProfile,
|
||||
bindCloudIdentityProfileWithPayload,
|
||||
deleteCloudIdentityProfileRevision,
|
||||
listCloudAccounts,
|
||||
deleteCloudAccount,
|
||||
getCloudIdentityProfiles,
|
||||
openCloudIdentityStorageFolder,
|
||||
previewGenerateCloudIdentityProfile,
|
||||
refreshAccountQuota,
|
||||
restoreCloudIdentityProfileRevision,
|
||||
restoreCloudBaselineProfile,
|
||||
switchCloudAccount,
|
||||
getAutoSwitchEnabled,
|
||||
setAutoSwitchEnabled,
|
||||
forcePollCloudMonitor,
|
||||
startAuthFlow,
|
||||
listOAuthClients,
|
||||
getActiveOAuthClient,
|
||||
setActiveOAuthClient,
|
||||
exportCloudAccounts,
|
||||
importCloudAccounts,
|
||||
} from './handler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
||||
import { getDeviceHardeningSnapshot } from '../device/handler';
|
||||
|
||||
const switchOwnerSchema = z.enum(['local-account-switch', 'cloud-account-switch']);
|
||||
const switchMetricBucketSchema = z.object({
|
||||
switchSuccess: z.number(),
|
||||
switchFailure: z.number(),
|
||||
rollbackAttempt: z.number(),
|
||||
rollbackSuccess: z.number(),
|
||||
rollbackFailure: z.number(),
|
||||
failureReasons: z.record(z.string(), z.number()),
|
||||
lastFailure: z
|
||||
.object({
|
||||
reason: z.string(),
|
||||
message: z.string(),
|
||||
occurredAt: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
const switchMetricsSnapshotSchema = z.object({
|
||||
local: switchMetricBucketSchema,
|
||||
cloud: switchMetricBucketSchema,
|
||||
});
|
||||
const switchGuardSnapshotSchema = z.object({
|
||||
activeOwner: switchOwnerSchema.nullable(),
|
||||
pendingOwners: z.array(switchOwnerSchema),
|
||||
pendingCount: z.number(),
|
||||
});
|
||||
const switchStatusSnapshotSchema = z.object({
|
||||
metrics: switchMetricsSnapshotSchema,
|
||||
guard: switchGuardSnapshotSchema,
|
||||
hardening: z.object({
|
||||
consecutiveApplyFailures: z.number(),
|
||||
safeModeActive: z.boolean(),
|
||||
safeModeUntil: z.number().nullable(),
|
||||
lastFailureReason: z.string().nullable(),
|
||||
lastFailureStage: z.string().nullable(),
|
||||
lastFailureAt: z.number().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, undefined> {
|
||||
const message = extractErrorMessage(error);
|
||||
const normalized = message.toLowerCase();
|
||||
|
||||
if (
|
||||
normalized.includes('unauthenticated') ||
|
||||
normalized.includes('unauthorized') ||
|
||||
normalized.includes('token may be expired') ||
|
||||
normalized.includes('re-login in geminiNexus ide')
|
||||
) {
|
||||
return new ORPCError('UNAUTHORIZED', { message });
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('no cloud account found in ide') ||
|
||||
normalized.includes('no oauth token found in ide state') ||
|
||||
normalized.includes('geminiNexus database not found')
|
||||
) {
|
||||
return new ORPCError('BAD_REQUEST', { message });
|
||||
}
|
||||
|
||||
return new ORPCError('INTERNAL_SERVER_ERROR', { message });
|
||||
}
|
||||
|
||||
export const cloudRouter = os.router({
|
||||
addGoogleAccount: os
|
||||
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode, input.oauthClientKey);
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
return listCloudAccounts();
|
||||
}),
|
||||
|
||||
deleteCloudAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteCloudAccount(input.accountId);
|
||||
}),
|
||||
|
||||
refreshAccountQuota: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return refreshAccountQuota(input.accountId);
|
||||
}),
|
||||
|
||||
switchCloudAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await switchCloudAccount(input.accountId);
|
||||
}),
|
||||
|
||||
getAutoSwitchEnabled: os.output(z.boolean()).handler(async () => {
|
||||
return getAutoSwitchEnabled();
|
||||
}),
|
||||
|
||||
setAutoSwitchEnabled: os
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await setAutoSwitchEnabled(input.enabled);
|
||||
}),
|
||||
|
||||
forcePollCloudMonitor: os.output(z.void()).handler(async () => {
|
||||
await forcePollCloudMonitor();
|
||||
}),
|
||||
|
||||
startAuthFlow: os
|
||||
.input(z.object({ oauthClientKey: z.string().optional() }).optional())
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await startAuthFlow(input?.oauthClientKey);
|
||||
}),
|
||||
|
||||
listOAuthClients: os
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
client_id: z.string(),
|
||||
is_active: z.boolean(),
|
||||
is_builtin: z.boolean(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handler(async () => {
|
||||
return listOAuthClients();
|
||||
}),
|
||||
|
||||
getActiveOAuthClient: os.output(z.object({ client_key: z.string() })).handler(async () => {
|
||||
return {
|
||||
client_key: getActiveOAuthClient(),
|
||||
};
|
||||
}),
|
||||
|
||||
setActiveOAuthClient: os
|
||||
.input(z.object({ clientKey: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
setActiveOAuthClient(input.clientKey);
|
||||
}),
|
||||
|
||||
setAccountProxy: os
|
||||
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
|
||||
getSwitchStatus: os.output(switchStatusSnapshotSchema).handler(async () => {
|
||||
return {
|
||||
metrics: getSwitchMetricsSnapshot(),
|
||||
guard: getSwitchGuardSnapshot(),
|
||||
hardening: getDeviceHardeningSnapshot(),
|
||||
};
|
||||
}),
|
||||
|
||||
getIdentityProfiles: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfilesSnapshotSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getCloudIdentityProfiles(input.accountId);
|
||||
}),
|
||||
|
||||
previewIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
|
||||
return previewGenerateCloudIdentityProfile();
|
||||
}),
|
||||
|
||||
bindIdentityProfile: os
|
||||
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindCloudIdentityProfile(input.accountId, input.mode);
|
||||
}),
|
||||
|
||||
bindIdentityProfileWithPayload: os
|
||||
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindCloudIdentityProfileWithPayload(input.accountId, input.profile);
|
||||
}),
|
||||
|
||||
restoreIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreCloudIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
restoreBaselineProfile: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreCloudBaselineProfile(input.accountId);
|
||||
}),
|
||||
|
||||
deleteIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteCloudIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||
await openCloudIdentityStorageFolder();
|
||||
}),
|
||||
|
||||
exportCloudAccounts: os
|
||||
.input(z.object({ stripTokens: z.boolean().default(false) }))
|
||||
.output(z.string())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await exportCloudAccounts(input.stripTokens);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
importCloudAccounts: os
|
||||
.input(
|
||||
z.object({
|
||||
jsonContent: z.string(),
|
||||
strategy: z.enum(['merge', 'overwrite', 'skip-existing']).default('merge'),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
imported: z.number(),
|
||||
skipped: z.number(),
|
||||
updated: z.number(),
|
||||
errors: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
import { z } from 'zod';
|
||||
import { os, ORPCError } from '@orpc/server';
|
||||
import {
|
||||
addGoogleAccount,
|
||||
bindCloudIdentityProfile,
|
||||
bindCloudIdentityProfileWithPayload,
|
||||
deleteCloudIdentityProfileRevision,
|
||||
listCloudAccounts,
|
||||
deleteCloudAccount,
|
||||
getCloudIdentityProfiles,
|
||||
openCloudIdentityStorageFolder,
|
||||
previewGenerateCloudIdentityProfile,
|
||||
refreshAccountQuota,
|
||||
restoreCloudIdentityProfileRevision,
|
||||
restoreCloudBaselineProfile,
|
||||
switchCloudAccount,
|
||||
getAutoSwitchEnabled,
|
||||
setAutoSwitchEnabled,
|
||||
forcePollCloudMonitor,
|
||||
startAuthFlow,
|
||||
listOAuthClients,
|
||||
getActiveOAuthClient,
|
||||
setActiveOAuthClient,
|
||||
exportCloudAccounts,
|
||||
importCloudAccounts,
|
||||
} from './handler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
||||
import { getDeviceHardeningSnapshot } from '../device/handler';
|
||||
|
||||
const switchOwnerSchema = z.enum(['local-account-switch', 'cloud-account-switch']);
|
||||
const switchMetricBucketSchema = z.object({
|
||||
switchSuccess: z.number(),
|
||||
switchFailure: z.number(),
|
||||
rollbackAttempt: z.number(),
|
||||
rollbackSuccess: z.number(),
|
||||
rollbackFailure: z.number(),
|
||||
failureReasons: z.record(z.string(), z.number()),
|
||||
lastFailure: z
|
||||
.object({
|
||||
reason: z.string(),
|
||||
message: z.string(),
|
||||
occurredAt: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
const switchMetricsSnapshotSchema = z.object({
|
||||
local: switchMetricBucketSchema,
|
||||
cloud: switchMetricBucketSchema,
|
||||
});
|
||||
const switchGuardSnapshotSchema = z.object({
|
||||
activeOwner: switchOwnerSchema.nullable(),
|
||||
pendingOwners: z.array(switchOwnerSchema),
|
||||
pendingCount: z.number(),
|
||||
});
|
||||
const switchStatusSnapshotSchema = z.object({
|
||||
metrics: switchMetricsSnapshotSchema,
|
||||
guard: switchGuardSnapshotSchema,
|
||||
hardening: z.object({
|
||||
consecutiveApplyFailures: z.number(),
|
||||
safeModeActive: z.boolean(),
|
||||
safeModeUntil: z.number().nullable(),
|
||||
lastFailureReason: z.string().nullable(),
|
||||
lastFailureStage: z.string().nullable(),
|
||||
lastFailureAt: z.number().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, undefined> {
|
||||
const message = extractErrorMessage(error);
|
||||
const normalized = message.toLowerCase();
|
||||
|
||||
if (
|
||||
normalized.includes('unauthenticated') ||
|
||||
normalized.includes('unauthorized') ||
|
||||
normalized.includes('token may be expired') ||
|
||||
normalized.includes('re-login in geminiNexus ide')
|
||||
) {
|
||||
return new ORPCError('UNAUTHORIZED', { message });
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('no cloud account found in ide') ||
|
||||
normalized.includes('no oauth token found in ide state') ||
|
||||
normalized.includes('geminiNexus database not found')
|
||||
) {
|
||||
return new ORPCError('BAD_REQUEST', { message });
|
||||
}
|
||||
|
||||
return new ORPCError('INTERNAL_SERVER_ERROR', { message });
|
||||
}
|
||||
|
||||
export const cloudRouter = os.router({
|
||||
addGoogleAccount: os
|
||||
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode, input.oauthClientKey);
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
return listCloudAccounts();
|
||||
}),
|
||||
|
||||
deleteCloudAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteCloudAccount(input.accountId);
|
||||
}),
|
||||
|
||||
refreshAccountQuota: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return refreshAccountQuota(input.accountId);
|
||||
}),
|
||||
|
||||
switchCloudAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await switchCloudAccount(input.accountId);
|
||||
}),
|
||||
|
||||
getAutoSwitchEnabled: os.output(z.boolean()).handler(async () => {
|
||||
return getAutoSwitchEnabled();
|
||||
}),
|
||||
|
||||
setAutoSwitchEnabled: os
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await setAutoSwitchEnabled(input.enabled);
|
||||
}),
|
||||
|
||||
forcePollCloudMonitor: os.output(z.void()).handler(async () => {
|
||||
await forcePollCloudMonitor();
|
||||
}),
|
||||
|
||||
startAuthFlow: os
|
||||
.input(z.object({ oauthClientKey: z.string().optional() }).optional())
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await startAuthFlow(input?.oauthClientKey);
|
||||
}),
|
||||
|
||||
listOAuthClients: os
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
client_id: z.string(),
|
||||
is_active: z.boolean(),
|
||||
is_builtin: z.boolean(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handler(async () => {
|
||||
return listOAuthClients();
|
||||
}),
|
||||
|
||||
getActiveOAuthClient: os.output(z.object({ client_key: z.string() })).handler(async () => {
|
||||
return {
|
||||
client_key: getActiveOAuthClient(),
|
||||
};
|
||||
}),
|
||||
|
||||
setActiveOAuthClient: os
|
||||
.input(z.object({ clientKey: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
setActiveOAuthClient(input.clientKey);
|
||||
}),
|
||||
|
||||
setAccountProxy: os
|
||||
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
|
||||
getSwitchStatus: os.output(switchStatusSnapshotSchema).handler(async () => {
|
||||
return {
|
||||
metrics: getSwitchMetricsSnapshot(),
|
||||
guard: getSwitchGuardSnapshot(),
|
||||
hardening: getDeviceHardeningSnapshot(),
|
||||
};
|
||||
}),
|
||||
|
||||
getIdentityProfiles: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfilesSnapshotSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getCloudIdentityProfiles(input.accountId);
|
||||
}),
|
||||
|
||||
previewIdentityProfile: os.output(DeviceProfileSchema).handler(async () => {
|
||||
return previewGenerateCloudIdentityProfile();
|
||||
}),
|
||||
|
||||
bindIdentityProfile: os
|
||||
.input(z.object({ accountId: z.string(), mode: z.enum(['capture', 'generate']) }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindCloudIdentityProfile(input.accountId, input.mode);
|
||||
}),
|
||||
|
||||
bindIdentityProfileWithPayload: os
|
||||
.input(z.object({ accountId: z.string(), profile: DeviceProfileSchema }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return bindCloudIdentityProfileWithPayload(input.accountId, input.profile);
|
||||
}),
|
||||
|
||||
restoreIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreCloudIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
restoreBaselineProfile: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(DeviceProfileSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return restoreCloudBaselineProfile(input.accountId);
|
||||
}),
|
||||
|
||||
deleteIdentityProfileRevision: os
|
||||
.input(z.object({ accountId: z.string(), versionId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteCloudIdentityProfileRevision(input.accountId, input.versionId);
|
||||
}),
|
||||
|
||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||
await openCloudIdentityStorageFolder();
|
||||
}),
|
||||
|
||||
exportCloudAccounts: os
|
||||
.input(z.object({ stripTokens: z.boolean().default(false) }))
|
||||
.output(z.string())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await exportCloudAccounts(input.stripTokens);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
importCloudAccounts: os
|
||||
.input(
|
||||
z.object({
|
||||
jsonContent: z.string(),
|
||||
strategy: z.enum(['merge', 'overwrite', 'skip-existing']).default('merge'),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
imported: z.number(),
|
||||
skipped: z.number(),
|
||||
updated: z.number(),
|
||||
errors: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
+22
-22
@@ -1,22 +1,22 @@
|
||||
import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
import { syncAutoStart } from '../../utils/autoStart';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { setServerConfig } from '../../server/server-config';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
}
|
||||
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
// Logic to notify proxy server if configuration changes (hot update)
|
||||
// Logic to update Tray if language changes
|
||||
// For now just save
|
||||
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||
await ConfigManager.saveConfig(config);
|
||||
setServerConfig(config.proxy);
|
||||
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||
if (previous.auto_startup !== config.auto_startup) {
|
||||
syncAutoStart(config);
|
||||
}
|
||||
}
|
||||
import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
import { syncAutoStart } from '../../utils/autoStart';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { setServerConfig } from '../../server/server-config';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
}
|
||||
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
// Logic to notify proxy server if configuration changes (hot update)
|
||||
// Logic to update Tray if language changes
|
||||
// For now just save
|
||||
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||
await ConfigManager.saveConfig(config);
|
||||
setServerConfig(config.proxy);
|
||||
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||
if (previous.auto_startup !== config.auto_startup) {
|
||||
syncAutoStart(config);
|
||||
}
|
||||
}
|
||||
+83
-83
@@ -1,83 +1,83 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AppConfig, DEFAULT_APP_CONFIG } from '../../types/config';
|
||||
import { getAppDataDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const CONFIG_FILENAME = 'gui_config.json';
|
||||
|
||||
export class ConfigManager {
|
||||
private static cachedConfig: AppConfig | null = null;
|
||||
private static saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private static getConfigPath(): string {
|
||||
const appDataDir = getAppDataDir();
|
||||
if (!fs.existsSync(appDataDir)) {
|
||||
fs.mkdirSync(appDataDir, { recursive: true });
|
||||
}
|
||||
return path.join(appDataDir, CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
static loadConfig(): AppConfig {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const raw = JSON.parse(content);
|
||||
|
||||
// Merge with default to ensure new fields are present
|
||||
// Zod parse helps validate
|
||||
const merged: AppConfig = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...raw,
|
||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||
};
|
||||
|
||||
// Fix deep merge for upstream_proxy if needed
|
||||
if (raw.proxy && raw.proxy.upstream_proxy) {
|
||||
merged.proxy.upstream_proxy = {
|
||||
...DEFAULT_APP_CONFIG.proxy.upstream_proxy,
|
||||
...raw.proxy.upstream_proxy,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Anthropic Mapping Map vs Object
|
||||
// In JSON it's object
|
||||
|
||||
this.cachedConfig = merged;
|
||||
return merged;
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to load config', e);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
static getCachedConfig(): AppConfig | null {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
static async saveConfig(config: AppConfig): Promise<void> {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
|
||||
this.saveQueue = this.saveQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.promises.writeFile(configPath, content, 'utf-8');
|
||||
this.cachedConfig = config;
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
return this.saveQueue;
|
||||
}
|
||||
}
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AppConfig, DEFAULT_APP_CONFIG } from '../../types/config';
|
||||
import { getAppDataDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const CONFIG_FILENAME = 'gui_config.json';
|
||||
|
||||
export class ConfigManager {
|
||||
private static cachedConfig: AppConfig | null = null;
|
||||
private static saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private static getConfigPath(): string {
|
||||
const appDataDir = getAppDataDir();
|
||||
if (!fs.existsSync(appDataDir)) {
|
||||
fs.mkdirSync(appDataDir, { recursive: true });
|
||||
}
|
||||
return path.join(appDataDir, CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
static loadConfig(): AppConfig {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const raw = JSON.parse(content);
|
||||
|
||||
// Merge with default to ensure new fields are present
|
||||
// Zod parse helps validate
|
||||
const merged: AppConfig = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...raw,
|
||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||
};
|
||||
|
||||
// Fix deep merge for upstream_proxy if needed
|
||||
if (raw.proxy && raw.proxy.upstream_proxy) {
|
||||
merged.proxy.upstream_proxy = {
|
||||
...DEFAULT_APP_CONFIG.proxy.upstream_proxy,
|
||||
...raw.proxy.upstream_proxy,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Anthropic Mapping Map vs Object
|
||||
// In JSON it's object
|
||||
|
||||
this.cachedConfig = merged;
|
||||
return merged;
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to load config', e);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
static getCachedConfig(): AppConfig | null {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
static async saveConfig(config: AppConfig): Promise<void> {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
|
||||
this.saveQueue = this.saveQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.promises.writeFile(configPath, content, 'utf-8');
|
||||
this.cachedConfig = config;
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
return this.saveQueue;
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -1,17 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { AppConfigSchema } from '../../types/config';
|
||||
import { loadConfig, saveConfig } from './handlers';
|
||||
|
||||
export const configRouter = os.router({
|
||||
load: os.output(AppConfigSchema).handler(async () => {
|
||||
return loadConfig();
|
||||
}),
|
||||
|
||||
save: os
|
||||
.input(AppConfigSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await saveConfig(input);
|
||||
}),
|
||||
});
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { AppConfigSchema } from '../../types/config';
|
||||
import { loadConfig, saveConfig } from './handlers';
|
||||
|
||||
export const configRouter = os.router({
|
||||
load: os.output(AppConfigSchema).handler(async () => {
|
||||
return loadConfig();
|
||||
}),
|
||||
|
||||
save: os
|
||||
.input(AppConfigSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await saveConfig(input);
|
||||
}),
|
||||
});
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
class IPCContext {
|
||||
public mainWindow: BrowserWindow | undefined;
|
||||
|
||||
public setMainWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
public get mainWindowContext() {
|
||||
// Return a middleware that checks for the window at execution time, not import time
|
||||
return os.middleware(({ next }) => {
|
||||
if (!this.mainWindow) {
|
||||
throw new Error('Main window is not set in IPC context.');
|
||||
}
|
||||
return next({
|
||||
context: {
|
||||
window: this.mainWindow,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcContext = new IPCContext();
|
||||
import { os } from '@orpc/server';
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
class IPCContext {
|
||||
public mainWindow: BrowserWindow | undefined;
|
||||
|
||||
public setMainWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
public get mainWindowContext() {
|
||||
// Return a middleware that checks for the window at execution time, not import time
|
||||
return os.middleware(({ next }) => {
|
||||
if (!this.mainWindow) {
|
||||
throw new Error('Main window is not set in IPC context.');
|
||||
}
|
||||
return next({
|
||||
context: {
|
||||
window: this.mainWindow,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcContext = new IPCContext();
|
||||
+1551
-1551
File diff suppressed because it is too large.
Load diff
@@ -1,34 +1,34 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
|
||||
export interface DbConfigOptions {
|
||||
readOnly?: boolean;
|
||||
busyTimeoutMs?: number;
|
||||
enableWal?: boolean;
|
||||
}
|
||||
|
||||
export interface DrizzleConnection {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof schema>;
|
||||
}
|
||||
|
||||
export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void {
|
||||
const busyTimeoutMs = options.busyTimeoutMs ?? 3000;
|
||||
const enableWal = options.enableWal ?? !options.readOnly;
|
||||
if (enableWal) {
|
||||
db.pragma('journal_mode = WAL');
|
||||
}
|
||||
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||
}
|
||||
|
||||
export function openDrizzleConnection(
|
||||
dbPath: string,
|
||||
options: Database.Options = {},
|
||||
config: DbConfigOptions = {},
|
||||
): DrizzleConnection {
|
||||
const raw = new Database(dbPath, options);
|
||||
configureDatabase(raw, config);
|
||||
const orm = drizzle(raw, { schema });
|
||||
return { raw, orm };
|
||||
}
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
|
||||
export interface DbConfigOptions {
|
||||
readOnly?: boolean;
|
||||
busyTimeoutMs?: number;
|
||||
enableWal?: boolean;
|
||||
}
|
||||
|
||||
export interface DrizzleConnection {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof schema>;
|
||||
}
|
||||
|
||||
export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void {
|
||||
const busyTimeoutMs = options.busyTimeoutMs ?? 3000;
|
||||
const enableWal = options.enableWal ?? !options.readOnly;
|
||||
if (enableWal) {
|
||||
db.pragma('journal_mode = WAL');
|
||||
}
|
||||
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||
}
|
||||
|
||||
export function openDrizzleConnection(
|
||||
dbPath: string,
|
||||
options: Database.Options = {},
|
||||
config: DbConfigOptions = {},
|
||||
): DrizzleConnection {
|
||||
const raw = new Database(dbPath, options);
|
||||
configureDatabase(raw, config);
|
||||
const orm = drizzle(raw, { schema });
|
||||
return { raw, orm };
|
||||
}
|
||||
+349
-349
@@ -1,349 +1,349 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { isString } from 'lodash-es';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { parseRow } from '../../utils/sqlite';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { itemTable } from './schema';
|
||||
|
||||
const KEYS_TO_BACKUP: ItemTableKey[] = [
|
||||
'geminiNexusAuthStatus',
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
];
|
||||
|
||||
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||
return openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: readOnly, fileMustExist: false },
|
||||
{ readOnly, busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the database and ensures WAL mode is enabled.
|
||||
* Should be called on application startup.
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
try {
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { raw } = getDatabaseConnection();
|
||||
raw.close();
|
||||
logger.info('Database initialized and verified (WAL mode)');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize database on startup', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the database file exists.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {void}
|
||||
*/
|
||||
function ensureDatabaseExists(dbPath: string): void {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Database file not found at ${dbPath}. Creating new database...`);
|
||||
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
// NOTE Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ItemTable (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
logger.info('Created new database with ItemTable schema.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to create new database', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a database connection.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||
const targetPath = dbPath || getGeminiNexusDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
throw new Error('No Gemini Nexus database path found');
|
||||
}
|
||||
|
||||
ensureDatabaseExists(targetPath);
|
||||
|
||||
try {
|
||||
return openIdeDb(targetPath);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
throw new Error('Database is locked. Please close Gemini Nexus before proceeding.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readItemValue(
|
||||
orm: ReturnType<typeof openDrizzleConnection>['orm'],
|
||||
key: string,
|
||||
context: string,
|
||||
): string | null {
|
||||
const rows = orm
|
||||
.select({ value: itemTable.value })
|
||||
.from(itemTable)
|
||||
.where(eq(itemTable.key, key))
|
||||
.all();
|
||||
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current account info.
|
||||
* @returns {AccountInfo} The current account info.
|
||||
*/
|
||||
export function getCurrentAccountInfo(): AccountInfo {
|
||||
// NOTE Database existence is now handled by getDatabaseConnection
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// Query for auth status
|
||||
const authValue = readItemValue(
|
||||
orm,
|
||||
'geminiNexusAuthStatus',
|
||||
'ide.itemTable.geminiNexusAuthStatus',
|
||||
);
|
||||
let authStatus = null;
|
||||
if (authValue) {
|
||||
try {
|
||||
authStatus = JSON.parse(authValue);
|
||||
} catch {
|
||||
// NOTE Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||
const initValue = readItemValue(
|
||||
orm,
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
||||
);
|
||||
let initState = null;
|
||||
if (initValue) {
|
||||
try {
|
||||
initState = JSON.parse(initValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.geminiNexus
|
||||
const googleValue = readItemValue(
|
||||
orm,
|
||||
'google.geminiNexus',
|
||||
'ide.itemTable.google.geminiNexus',
|
||||
);
|
||||
let googleState = null;
|
||||
if (googleValue) {
|
||||
try {
|
||||
googleState = JSON.parse(googleValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Query for geminiNexusUserSettings.allUserSettings
|
||||
const settingsValue = readItemValue(
|
||||
orm,
|
||||
'geminiNexusUserSettings.allUserSettings',
|
||||
'ide.itemTable.geminiNexusUserSettings.allUserSettings',
|
||||
);
|
||||
let settingsState = null;
|
||||
if (settingsValue) {
|
||||
try {
|
||||
settingsState = JSON.parse(settingsValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to find email in object
|
||||
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
||||
if (!obj) return '';
|
||||
if (isString(obj.email)) return obj.email;
|
||||
if (obj.user && isString(obj.user.email)) return obj.user.email;
|
||||
return '';
|
||||
};
|
||||
|
||||
const email =
|
||||
findEmail(authStatus) ||
|
||||
findEmail(initState) ||
|
||||
findEmail(googleState) ||
|
||||
findEmail(settingsState) ||
|
||||
'';
|
||||
|
||||
const name = authStatus?.user?.name || initState?.user?.name || authStatus?.name || '';
|
||||
const isAuthenticated = !!email;
|
||||
|
||||
logger.info(`Account info: authenticated=${isAuthenticated}, email=${email || 'none'}`);
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
isAuthenticated,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get current account info', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// NOTE Backup only specific keys
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
|
||||
if (value) {
|
||||
try {
|
||||
data[key] = JSON.parse(value);
|
||||
} catch {
|
||||
data[key] = value;
|
||||
}
|
||||
logger.debug(`Backed up key: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Key not found: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Add metadata
|
||||
data['account_email'] = account.email;
|
||||
data['backup_time'] = new Date().toISOString();
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
account,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to backup account', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the account data to the database.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @throws {Error} If the backup data cannot be restored.
|
||||
*/
|
||||
export function restoreAccount(backup: AccountBackupData): void {
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
throw new Error('No Gemini Nexus database paths found');
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
// NOTE Restore main DB
|
||||
if (_restoreSingleDb(dbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// NOTE Restore backup DB (if exists)
|
||||
const backupDbPath = dbPath.replace(/\.vscdb$/, '.vscdb.backup');
|
||||
if (fs.existsSync(backupDbPath)) {
|
||||
if (_restoreSingleDb(backupDbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
logger.info(`Account data restored successfully to ${successCount} files`);
|
||||
} else {
|
||||
throw new Error('Failed to restore account data to any database file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a single database file.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @returns {boolean} True if the database file was restored successfully, false otherwise.
|
||||
*/
|
||||
function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Restoring database: ${dbPath}`);
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
|
||||
try {
|
||||
connection = getDatabaseConnection(dbPath);
|
||||
const { orm } = connection;
|
||||
orm.transaction((tx) => {
|
||||
// NOTE Only restore the keys that were backed up
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
if (key in backup.data) {
|
||||
const value = backup.data[key];
|
||||
const stringValue = isString(value) ? value : JSON.stringify(value);
|
||||
tx.insert(itemTable)
|
||||
.values({ key, value: stringValue })
|
||||
.onConflictDoUpdate({
|
||||
target: itemTable.key,
|
||||
set: { value: stringValue },
|
||||
})
|
||||
.run();
|
||||
logger.debug(`Restored key: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
logger.info(`Database restoration complete: ${dbPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||
return false;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { isString } from 'lodash-es';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { parseRow } from '../../utils/sqlite';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { itemTable } from './schema';
|
||||
|
||||
const KEYS_TO_BACKUP: ItemTableKey[] = [
|
||||
'geminiNexusAuthStatus',
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
];
|
||||
|
||||
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||
return openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: readOnly, fileMustExist: false },
|
||||
{ readOnly, busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the database and ensures WAL mode is enabled.
|
||||
* Should be called on application startup.
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
try {
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { raw } = getDatabaseConnection();
|
||||
raw.close();
|
||||
logger.info('Database initialized and verified (WAL mode)');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize database on startup', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the database file exists.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {void}
|
||||
*/
|
||||
function ensureDatabaseExists(dbPath: string): void {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Database file not found at ${dbPath}. Creating new database...`);
|
||||
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
// NOTE Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ItemTable (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
logger.info('Created new database with ItemTable schema.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to create new database', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a database connection.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||
const targetPath = dbPath || getGeminiNexusDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
throw new Error('No Gemini Nexus database path found');
|
||||
}
|
||||
|
||||
ensureDatabaseExists(targetPath);
|
||||
|
||||
try {
|
||||
return openIdeDb(targetPath);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
throw new Error('Database is locked. Please close Gemini Nexus before proceeding.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readItemValue(
|
||||
orm: ReturnType<typeof openDrizzleConnection>['orm'],
|
||||
key: string,
|
||||
context: string,
|
||||
): string | null {
|
||||
const rows = orm
|
||||
.select({ value: itemTable.value })
|
||||
.from(itemTable)
|
||||
.where(eq(itemTable.key, key))
|
||||
.all();
|
||||
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current account info.
|
||||
* @returns {AccountInfo} The current account info.
|
||||
*/
|
||||
export function getCurrentAccountInfo(): AccountInfo {
|
||||
// NOTE Database existence is now handled by getDatabaseConnection
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// Query for auth status
|
||||
const authValue = readItemValue(
|
||||
orm,
|
||||
'geminiNexusAuthStatus',
|
||||
'ide.itemTable.geminiNexusAuthStatus',
|
||||
);
|
||||
let authStatus = null;
|
||||
if (authValue) {
|
||||
try {
|
||||
authStatus = JSON.parse(authValue);
|
||||
} catch {
|
||||
// NOTE Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||
const initValue = readItemValue(
|
||||
orm,
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
||||
);
|
||||
let initState = null;
|
||||
if (initValue) {
|
||||
try {
|
||||
initState = JSON.parse(initValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.geminiNexus
|
||||
const googleValue = readItemValue(
|
||||
orm,
|
||||
'google.geminiNexus',
|
||||
'ide.itemTable.google.geminiNexus',
|
||||
);
|
||||
let googleState = null;
|
||||
if (googleValue) {
|
||||
try {
|
||||
googleState = JSON.parse(googleValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Query for geminiNexusUserSettings.allUserSettings
|
||||
const settingsValue = readItemValue(
|
||||
orm,
|
||||
'geminiNexusUserSettings.allUserSettings',
|
||||
'ide.itemTable.geminiNexusUserSettings.allUserSettings',
|
||||
);
|
||||
let settingsState = null;
|
||||
if (settingsValue) {
|
||||
try {
|
||||
settingsState = JSON.parse(settingsValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to find email in object
|
||||
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
||||
if (!obj) return '';
|
||||
if (isString(obj.email)) return obj.email;
|
||||
if (obj.user && isString(obj.user.email)) return obj.user.email;
|
||||
return '';
|
||||
};
|
||||
|
||||
const email =
|
||||
findEmail(authStatus) ||
|
||||
findEmail(initState) ||
|
||||
findEmail(googleState) ||
|
||||
findEmail(settingsState) ||
|
||||
'';
|
||||
|
||||
const name = authStatus?.user?.name || initState?.user?.name || authStatus?.name || '';
|
||||
const isAuthenticated = !!email;
|
||||
|
||||
logger.info(`Account info: authenticated=${isAuthenticated}, email=${email || 'none'}`);
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
isAuthenticated,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get current account info', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// NOTE Backup only specific keys
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
|
||||
if (value) {
|
||||
try {
|
||||
data[key] = JSON.parse(value);
|
||||
} catch {
|
||||
data[key] = value;
|
||||
}
|
||||
logger.debug(`Backed up key: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Key not found: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Add metadata
|
||||
data['account_email'] = account.email;
|
||||
data['backup_time'] = new Date().toISOString();
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
account,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to backup account', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the account data to the database.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @throws {Error} If the backup data cannot be restored.
|
||||
*/
|
||||
export function restoreAccount(backup: AccountBackupData): void {
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
throw new Error('No Gemini Nexus database paths found');
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
// NOTE Restore main DB
|
||||
if (_restoreSingleDb(dbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// NOTE Restore backup DB (if exists)
|
||||
const backupDbPath = dbPath.replace(/\.vscdb$/, '.vscdb.backup');
|
||||
if (fs.existsSync(backupDbPath)) {
|
||||
if (_restoreSingleDb(backupDbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
logger.info(`Account data restored successfully to ${successCount} files`);
|
||||
} else {
|
||||
throw new Error('Failed to restore account data to any database file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a single database file.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @returns {boolean} True if the database file was restored successfully, false otherwise.
|
||||
*/
|
||||
function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Restoring database: ${dbPath}`);
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
|
||||
try {
|
||||
connection = getDatabaseConnection(dbPath);
|
||||
const { orm } = connection;
|
||||
orm.transaction((tx) => {
|
||||
// NOTE Only restore the keys that were backed up
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
if (key in backup.data) {
|
||||
const value = backup.data[key];
|
||||
const stringValue = isString(value) ? value : JSON.stringify(value);
|
||||
tx.insert(itemTable)
|
||||
.values({ key, value: stringValue })
|
||||
.onConflictDoUpdate({
|
||||
target: itemTable.key,
|
||||
set: { value: stringValue },
|
||||
})
|
||||
.run();
|
||||
logger.debug(`Restored key: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
logger.info(`Database restoration complete: ${dbPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||
return false;
|
||||
} finally {
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,452 +1,452 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
|
||||
|
||||
export interface RecordProxyRequestParams {
|
||||
endpoint: string;
|
||||
statusCode: number;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
accountId?: string | null;
|
||||
tokensPrompt?: number | null;
|
||||
tokensCompletion?: number | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
let proxyMetricsDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getProxyMetricsDb() {
|
||||
if (!proxyMetricsDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
const conn = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
ensureProxyMetricsTable(conn.raw);
|
||||
proxyMetricsDbConnection = conn;
|
||||
}
|
||||
return proxyMetricsDbConnection;
|
||||
}
|
||||
|
||||
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
|
||||
try {
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
endpoint TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
account_id TEXT,
|
||||
tokens_prompt INTEGER,
|
||||
tokens_completion INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS traffic_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT,
|
||||
status INTEGER,
|
||||
headers_json TEXT,
|
||||
body_json TEXT,
|
||||
chunk_json TEXT,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
error_stack TEXT,
|
||||
error_status INTEGER,
|
||||
metadata_json TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyMetricsRepo {
|
||||
static recordRequest(params: RecordProxyRequestParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(proxyRequestMetrics)
|
||||
.values({
|
||||
endpoint: params.endpoint,
|
||||
statusCode: params.statusCode,
|
||||
durationMs: params.durationMs,
|
||||
timestamp: params.timestamp,
|
||||
accountId: params.accountId ?? null,
|
||||
tokensPrompt: params.tokensPrompt ?? null,
|
||||
tokensCompletion: params.tokensCompletion ?? null,
|
||||
errorMessage: params.errorMessage ?? null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record proxy request metric', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecentAggregates(): {
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60_000;
|
||||
const fiveMinutesAgo = now - 5 * 60_000;
|
||||
|
||||
try {
|
||||
const requestsPerMinuteRow = raw
|
||||
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
|
||||
.get(oneMinuteAgo) as { count: number } | undefined;
|
||||
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
|
||||
|
||||
const aggregatesRow = raw
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
AVG(duration_ms) as avgLatency,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics
|
||||
WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(fiveMinutesAgo) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
|
||||
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
|
||||
|
||||
return {
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent proxy metrics aggregates', error);
|
||||
return {
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static recordConnectionSnapshot(count: number): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record connection snapshot', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
avgActiveConnections: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
try {
|
||||
const reqRow = raw
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(cutoff) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const totalRequests = reqRow?.total ?? 0;
|
||||
const minutes = Math.max(windowMs / 60_000, 1);
|
||||
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||
|
||||
const connRow = raw
|
||||
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
|
||||
.get(cutoff) as { avg: number | null } | undefined;
|
||||
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
avgActiveConnections,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get historical proxy metrics aggregates', error);
|
||||
return {
|
||||
totalRequests: 0,
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
avgActiveConnections: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOldRecords(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old proxy metrics records', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface InsertTrafficLogParams {
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class TrafficLogsRepo {
|
||||
static insert(params: InsertTrafficLogParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(trafficLogs)
|
||||
.values({
|
||||
timestamp: params.timestamp,
|
||||
direction: params.direction,
|
||||
requestId: params.requestId,
|
||||
endpoint: params.endpoint,
|
||||
method: params.method ?? null,
|
||||
status: params.status ?? null,
|
||||
headersJson: params.headers ? JSON.stringify(params.headers) : null,
|
||||
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
|
||||
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
|
||||
durationMs: params.durationMs ?? null,
|
||||
errorMessage: params.error?.message ?? null,
|
||||
errorStack: params.error?.stack ?? null,
|
||||
errorStatus: params.error?.status ?? null,
|
||||
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to insert traffic log', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecent(limit: number = 50, direction?: string): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
let sql = `SELECT * FROM traffic_logs`;
|
||||
const args: (number | string)[] = [];
|
||||
if (direction) {
|
||||
sql += ` WHERE direction = ?`;
|
||||
args.push(direction);
|
||||
}
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
args.push(limit);
|
||||
const rows = raw.prepare(sql).all(...args) as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
|
||||
direction: string;
|
||||
endpoint: string;
|
||||
cnt: number;
|
||||
}>;
|
||||
const byDirection: Record<string, number> = {};
|
||||
const byEndpoint: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
total += r.cnt;
|
||||
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
|
||||
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
|
||||
}
|
||||
return { byDirection, byEndpoint, total };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { byDirection: {}, byEndpoint: {}, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
static getAllForExport(): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all traffic logs for export', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOld(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old traffic logs', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
|
||||
|
||||
export interface RecordProxyRequestParams {
|
||||
endpoint: string;
|
||||
statusCode: number;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
accountId?: string | null;
|
||||
tokensPrompt?: number | null;
|
||||
tokensCompletion?: number | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
let proxyMetricsDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getProxyMetricsDb() {
|
||||
if (!proxyMetricsDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
const conn = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
ensureProxyMetricsTable(conn.raw);
|
||||
proxyMetricsDbConnection = conn;
|
||||
}
|
||||
return proxyMetricsDbConnection;
|
||||
}
|
||||
|
||||
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
|
||||
try {
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
endpoint TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
account_id TEXT,
|
||||
tokens_prompt INTEGER,
|
||||
tokens_completion INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS traffic_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT,
|
||||
status INTEGER,
|
||||
headers_json TEXT,
|
||||
body_json TEXT,
|
||||
chunk_json TEXT,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
error_stack TEXT,
|
||||
error_status INTEGER,
|
||||
metadata_json TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyMetricsRepo {
|
||||
static recordRequest(params: RecordProxyRequestParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(proxyRequestMetrics)
|
||||
.values({
|
||||
endpoint: params.endpoint,
|
||||
statusCode: params.statusCode,
|
||||
durationMs: params.durationMs,
|
||||
timestamp: params.timestamp,
|
||||
accountId: params.accountId ?? null,
|
||||
tokensPrompt: params.tokensPrompt ?? null,
|
||||
tokensCompletion: params.tokensCompletion ?? null,
|
||||
errorMessage: params.errorMessage ?? null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record proxy request metric', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecentAggregates(): {
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60_000;
|
||||
const fiveMinutesAgo = now - 5 * 60_000;
|
||||
|
||||
try {
|
||||
const requestsPerMinuteRow = raw
|
||||
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
|
||||
.get(oneMinuteAgo) as { count: number } | undefined;
|
||||
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
|
||||
|
||||
const aggregatesRow = raw
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
AVG(duration_ms) as avgLatency,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics
|
||||
WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(fiveMinutesAgo) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
|
||||
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
|
||||
|
||||
return {
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent proxy metrics aggregates', error);
|
||||
return {
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static recordConnectionSnapshot(count: number): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record connection snapshot', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
avgActiveConnections: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
try {
|
||||
const reqRow = raw
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(cutoff) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const totalRequests = reqRow?.total ?? 0;
|
||||
const minutes = Math.max(windowMs / 60_000, 1);
|
||||
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||
|
||||
const connRow = raw
|
||||
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
|
||||
.get(cutoff) as { avg: number | null } | undefined;
|
||||
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
avgActiveConnections,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get historical proxy metrics aggregates', error);
|
||||
return {
|
||||
totalRequests: 0,
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
avgActiveConnections: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOldRecords(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old proxy metrics records', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface InsertTrafficLogParams {
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class TrafficLogsRepo {
|
||||
static insert(params: InsertTrafficLogParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(trafficLogs)
|
||||
.values({
|
||||
timestamp: params.timestamp,
|
||||
direction: params.direction,
|
||||
requestId: params.requestId,
|
||||
endpoint: params.endpoint,
|
||||
method: params.method ?? null,
|
||||
status: params.status ?? null,
|
||||
headersJson: params.headers ? JSON.stringify(params.headers) : null,
|
||||
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
|
||||
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
|
||||
durationMs: params.durationMs ?? null,
|
||||
errorMessage: params.error?.message ?? null,
|
||||
errorStack: params.error?.stack ?? null,
|
||||
errorStatus: params.error?.status ?? null,
|
||||
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to insert traffic log', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecent(limit: number = 50, direction?: string): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
let sql = `SELECT * FROM traffic_logs`;
|
||||
const args: (number | string)[] = [];
|
||||
if (direction) {
|
||||
sql += ` WHERE direction = ?`;
|
||||
args.push(direction);
|
||||
}
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
args.push(limit);
|
||||
const rows = raw.prepare(sql).all(...args) as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
|
||||
direction: string;
|
||||
endpoint: string;
|
||||
cnt: number;
|
||||
}>;
|
||||
const byDirection: Record<string, number> = {};
|
||||
const byEndpoint: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
total += r.cnt;
|
||||
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
|
||||
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
|
||||
}
|
||||
return { byDirection, byEndpoint, total };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { byDirection: {}, byEndpoint: {}, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
static getAllForExport(): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all traffic logs for export', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOld(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old traffic logs', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-24
@@ -1,24 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
|
||||
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
|
||||
|
||||
export const databaseRouter = os.router({
|
||||
backupAccount: os
|
||||
.input(AccountSchema)
|
||||
.output(AccountBackupDataSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return backupAccount(input);
|
||||
}),
|
||||
|
||||
restoreAccount: os
|
||||
.input(AccountBackupDataSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
restoreAccount(input);
|
||||
}),
|
||||
|
||||
getCurrentAccountInfo: os.output(AccountInfoSchema).handler(async () => {
|
||||
return getCurrentAccountInfo();
|
||||
}),
|
||||
});
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
|
||||
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
|
||||
|
||||
export const databaseRouter = os.router({
|
||||
backupAccount: os
|
||||
.input(AccountSchema)
|
||||
.output(AccountBackupDataSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return backupAccount(input);
|
||||
}),
|
||||
|
||||
restoreAccount: os
|
||||
.input(AccountBackupDataSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
restoreAccount(input);
|
||||
}),
|
||||
|
||||
getCurrentAccountInfo: os.output(AccountInfoSchema).handler(async () => {
|
||||
return getCurrentAccountInfo();
|
||||
}),
|
||||
});
|
||||
+77
-77
@@ -1,77 +1,77 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const accounts = sqliteTable('accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
provider: text('provider').notNull(),
|
||||
email: text('email').notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
tokenJson: text('token_json').notNull(),
|
||||
quotaJson: text('quota_json'),
|
||||
deviceProfileJson: text('device_profile_json'),
|
||||
deviceHistoryJson: text('device_history_json'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastUsed: integer('last_used').notNull(),
|
||||
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
|
||||
statusReason: text('status_reason'),
|
||||
isActive: integer('is_active').notNull().default(0),
|
||||
proxyUrl: text('proxy_url'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const itemTable = sqliteTable('ItemTable', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'),
|
||||
});
|
||||
|
||||
export const tokenUsage = sqliteTable('token_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: text('account_id').notNull(),
|
||||
model: text('model').notNull(),
|
||||
promptTokens: integer('prompt_tokens').notNull().default(0),
|
||||
completionTokens: integer('completion_tokens').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
requestType: text('request_type'),
|
||||
isEstimated: integer('is_estimated'),
|
||||
});
|
||||
|
||||
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
statusCode: integer('status_code').notNull(),
|
||||
durationMs: integer('duration_ms').notNull(),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
accountId: text('account_id'),
|
||||
tokensPrompt: integer('tokens_prompt'),
|
||||
tokensCompletion: integer('tokens_completion'),
|
||||
errorMessage: text('error_message'),
|
||||
});
|
||||
|
||||
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
count: integer('count').notNull().default(0),
|
||||
});
|
||||
|
||||
export const trafficLogs = sqliteTable('traffic_logs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
direction: text('direction').notNull(),
|
||||
requestId: text('request_id').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
method: text('method'),
|
||||
status: integer('status'),
|
||||
headersJson: text('headers_json'),
|
||||
bodyJson: text('body_json'),
|
||||
chunkJson: text('chunk_json'),
|
||||
durationMs: integer('duration_ms'),
|
||||
errorMessage: text('error_message'),
|
||||
errorStack: text('error_stack'),
|
||||
errorStatus: integer('error_status'),
|
||||
metadataJson: text('metadata_json'),
|
||||
});
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const accounts = sqliteTable('accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
provider: text('provider').notNull(),
|
||||
email: text('email').notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
tokenJson: text('token_json').notNull(),
|
||||
quotaJson: text('quota_json'),
|
||||
deviceProfileJson: text('device_profile_json'),
|
||||
deviceHistoryJson: text('device_history_json'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastUsed: integer('last_used').notNull(),
|
||||
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
|
||||
statusReason: text('status_reason'),
|
||||
isActive: integer('is_active').notNull().default(0),
|
||||
proxyUrl: text('proxy_url'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const itemTable = sqliteTable('ItemTable', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'),
|
||||
});
|
||||
|
||||
export const tokenUsage = sqliteTable('token_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: text('account_id').notNull(),
|
||||
model: text('model').notNull(),
|
||||
promptTokens: integer('prompt_tokens').notNull().default(0),
|
||||
completionTokens: integer('completion_tokens').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
requestType: text('request_type'),
|
||||
isEstimated: integer('is_estimated'),
|
||||
});
|
||||
|
||||
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
statusCode: integer('status_code').notNull(),
|
||||
durationMs: integer('duration_ms').notNull(),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
accountId: text('account_id'),
|
||||
tokensPrompt: integer('tokens_prompt'),
|
||||
tokensCompletion: integer('tokens_completion'),
|
||||
errorMessage: text('error_message'),
|
||||
});
|
||||
|
||||
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
count: integer('count').notNull().default(0),
|
||||
});
|
||||
|
||||
export const trafficLogs = sqliteTable('traffic_logs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
direction: text('direction').notNull(),
|
||||
requestId: text('request_id').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
method: text('method'),
|
||||
status: integer('status'),
|
||||
headersJson: text('headers_json'),
|
||||
bodyJson: text('body_json'),
|
||||
chunkJson: text('chunk_json'),
|
||||
durationMs: integer('duration_ms'),
|
||||
errorMessage: text('error_message'),
|
||||
errorStack: text('error_stack'),
|
||||
errorStatus: integer('error_status'),
|
||||
metadataJson: text('metadata_json'),
|
||||
});
|
||||
+274
-274
@@ -1,274 +1,274 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { tokenUsage } from './schema';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
export interface RecordUsageParams {
|
||||
accountId: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
timestamp: number;
|
||||
requestType?: string;
|
||||
isEstimated?: boolean;
|
||||
}
|
||||
|
||||
let usageDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getUsageDb() {
|
||||
if (!usageDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
usageDbConnection = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
return usageDbConnection;
|
||||
}
|
||||
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
|
||||
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
|
||||
export class TokenUsageRepo {
|
||||
static recordUsage(params: RecordUsageParams): boolean {
|
||||
const { orm } = getUsageDb();
|
||||
try {
|
||||
orm
|
||||
.insert(tokenUsage)
|
||||
.values({
|
||||
accountId: params.accountId,
|
||||
model: params.model,
|
||||
promptTokens: params.promptTokens,
|
||||
completionTokens: params.completionTokens,
|
||||
totalTokens: params.totalTokens,
|
||||
timestamp: params.timestamp,
|
||||
requestType: params.requestType ?? null,
|
||||
isEstimated: params.isEstimated ? 1 : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record token usage', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByHour(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => HourlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get hourly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByDay(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => DailyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get daily usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByWeek(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => WeeklyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get weekly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByMonth(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => MonthlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get monthly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByModel(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
model,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY model
|
||||
ORDER BY totalTokens DESC
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => ModelUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { tokenUsage } from './schema';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
export interface RecordUsageParams {
|
||||
accountId: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
timestamp: number;
|
||||
requestType?: string;
|
||||
isEstimated?: boolean;
|
||||
}
|
||||
|
||||
let usageDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getUsageDb() {
|
||||
if (!usageDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
usageDbConnection = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
return usageDbConnection;
|
||||
}
|
||||
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
|
||||
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
|
||||
export class TokenUsageRepo {
|
||||
static recordUsage(params: RecordUsageParams): boolean {
|
||||
const { orm } = getUsageDb();
|
||||
try {
|
||||
orm
|
||||
.insert(tokenUsage)
|
||||
.values({
|
||||
accountId: params.accountId,
|
||||
model: params.model,
|
||||
promptTokens: params.promptTokens,
|
||||
completionTokens: params.completionTokens,
|
||||
totalTokens: params.totalTokens,
|
||||
timestamp: params.timestamp,
|
||||
requestType: params.requestType ?? null,
|
||||
isEstimated: params.isEstimated ? 1 : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record token usage', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByHour(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => HourlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get hourly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByDay(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => DailyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get daily usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByWeek(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => WeeklyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get weekly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByMonth(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => MonthlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get monthly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByModel(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
model,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY model
|
||||
ORDER BY totalTokens DESC
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => ModelUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
+717
-717
File diff suppressed because it is too large.
Load diff
+60
-60
@@ -1,60 +1,60 @@
|
||||
/**
|
||||
* Gateway IPC Handlers
|
||||
* Provides ORPC handlers for controlling the API Gateway service (NestJS version)
|
||||
*/
|
||||
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Start the gateway server (NestJS)
|
||||
*/
|
||||
export const startGateway = async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
// Stop if already running
|
||||
await stopNestServer();
|
||||
|
||||
// Load full config and start NestJS server
|
||||
const config = ConfigManager.loadConfig();
|
||||
const proxyConfig = { ...config.proxy, port };
|
||||
|
||||
return await bootstrapNestServer(proxyConfig);
|
||||
} catch (e) {
|
||||
logger.error('Failed to start gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the gateway server (NestJS)
|
||||
*/
|
||||
export const stopGateway = async (): Promise<boolean> => {
|
||||
try {
|
||||
return await stopNestServer();
|
||||
} catch (e) {
|
||||
logger.error('Failed to stop gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get gateway status (NestJS)
|
||||
*/
|
||||
export const getGatewayStatus = async () => {
|
||||
return getNestServerStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*/
|
||||
export const generateApiKey = async (): Promise<string> => {
|
||||
const newKey = `sk-${uuidv4().replace(/-/g, '')}`;
|
||||
|
||||
// Save to config
|
||||
const config = ConfigManager.loadConfig();
|
||||
config.proxy.api_key = newKey;
|
||||
await ConfigManager.saveConfig(config);
|
||||
|
||||
return newKey;
|
||||
};
|
||||
/**
|
||||
* Gateway IPC Handlers
|
||||
* Provides ORPC handlers for controlling the API Gateway service (NestJS version)
|
||||
*/
|
||||
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Start the gateway server (NestJS)
|
||||
*/
|
||||
export const startGateway = async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
// Stop if already running
|
||||
await stopNestServer();
|
||||
|
||||
// Load full config and start NestJS server
|
||||
const config = ConfigManager.loadConfig();
|
||||
const proxyConfig = { ...config.proxy, port };
|
||||
|
||||
return await bootstrapNestServer(proxyConfig);
|
||||
} catch (e) {
|
||||
logger.error('Failed to start gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the gateway server (NestJS)
|
||||
*/
|
||||
export const stopGateway = async (): Promise<boolean> => {
|
||||
try {
|
||||
return await stopNestServer();
|
||||
} catch (e) {
|
||||
logger.error('Failed to stop gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get gateway status (NestJS)
|
||||
*/
|
||||
export const getGatewayStatus = async () => {
|
||||
return getNestServerStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*/
|
||||
export const generateApiKey = async (): Promise<string> => {
|
||||
const newKey = `sk-${uuidv4().replace(/-/g, '')}`;
|
||||
|
||||
// Save to config
|
||||
const config = ConfigManager.loadConfig();
|
||||
config.proxy.api_key = newKey;
|
||||
await ConfigManager.saveConfig(config);
|
||||
|
||||
return newKey;
|
||||
};
|
||||
+36
-36
@@ -1,36 +1,36 @@
|
||||
/**
|
||||
* Gateway ORPC Router
|
||||
* Provides routes for controlling the API Gateway service
|
||||
*/
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { startGateway, stopGateway, getGatewayStatus, generateApiKey } from './handlers';
|
||||
|
||||
export const gatewayRouter = os.prefix('/gateway').router({
|
||||
start: os
|
||||
.input(z.object({ port: z.number().min(1024).max(65535) }))
|
||||
.handler(async ({ input }) => {
|
||||
const success = await startGateway(input.port);
|
||||
if (!success) {
|
||||
throw new Error('Failed to start gateway');
|
||||
}
|
||||
return { success };
|
||||
}),
|
||||
|
||||
stop: os.handler(async () => {
|
||||
const success = await stopGateway();
|
||||
if (!success) {
|
||||
throw new Error('Failed to stop gateway');
|
||||
}
|
||||
return { success };
|
||||
}),
|
||||
|
||||
status: os.handler(async () => {
|
||||
return getGatewayStatus();
|
||||
}),
|
||||
|
||||
generateKey: os.handler(async () => {
|
||||
const newKey = await generateApiKey();
|
||||
return { api_key: newKey };
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* Gateway ORPC Router
|
||||
* Provides routes for controlling the API Gateway service
|
||||
*/
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { startGateway, stopGateway, getGatewayStatus, generateApiKey } from './handlers';
|
||||
|
||||
export const gatewayRouter = os.prefix('/gateway').router({
|
||||
start: os
|
||||
.input(z.object({ port: z.number().min(1024).max(65535) }))
|
||||
.handler(async ({ input }) => {
|
||||
const success = await startGateway(input.port);
|
||||
if (!success) {
|
||||
throw new Error('Failed to start gateway');
|
||||
}
|
||||
return { success };
|
||||
}),
|
||||
|
||||
stop: os.handler(async () => {
|
||||
const success = await stopGateway();
|
||||
if (!success) {
|
||||
throw new Error('Failed to stop gateway');
|
||||
}
|
||||
return { success };
|
||||
}),
|
||||
|
||||
status: os.handler(async () => {
|
||||
return getGatewayStatus();
|
||||
}),
|
||||
|
||||
generateKey: os.handler(async () => {
|
||||
const newKey = await generateApiKey();
|
||||
return { api_key: newKey };
|
||||
}),
|
||||
});
|
||||
+34
-34
@@ -1,34 +1,34 @@
|
||||
import { RPCHandler } from '@orpc/server/message-port';
|
||||
import { router } from './router';
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Debug logging
|
||||
try {
|
||||
logger.info('Initializing rpcHandler');
|
||||
logger.info(`Router keys: ${Object.keys(router).join(', ')}`);
|
||||
if (router) {
|
||||
const proc = router.proc || (router as any)._def?.router?.proc; // Check 'proc' not 'process'
|
||||
logger.info(`Process router (proc) check: ${!!proc}`);
|
||||
try {
|
||||
if (proc) {
|
||||
// @ts-ignore
|
||||
const procKeys = proc.$handlers ? Object.keys(proc.$handlers) : Object.keys(proc);
|
||||
// Try to find handlers in _def if not direct properties
|
||||
// @ts-ignore
|
||||
const defKeys = proc._def?.router ? Object.keys(proc._def.router) : [];
|
||||
logger.info(`Process router keys (direct): ${JSON.stringify(procKeys)}`);
|
||||
logger.info(`Process router keys (_def): ${JSON.stringify(defKeys)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.info('Could not stringify process router keys', e);
|
||||
}
|
||||
|
||||
const db = router.database;
|
||||
logger.info(`Database router check: ${!!db}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error logging router:', e);
|
||||
}
|
||||
|
||||
export const rpcHandler: RPCHandler<Record<never, never>> = new RPCHandler(router);
|
||||
import { RPCHandler } from '@orpc/server/message-port';
|
||||
import { router } from './router';
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Debug logging
|
||||
try {
|
||||
logger.info('Initializing rpcHandler');
|
||||
logger.info(`Router keys: ${Object.keys(router).join(', ')}`);
|
||||
if (router) {
|
||||
const proc = router.proc || (router as any)._def?.router?.proc; // Check 'proc' not 'process'
|
||||
logger.info(`Process router (proc) check: ${!!proc}`);
|
||||
try {
|
||||
if (proc) {
|
||||
// @ts-ignore
|
||||
const procKeys = proc.$handlers ? Object.keys(proc.$handlers) : Object.keys(proc);
|
||||
// Try to find handlers in _def if not direct properties
|
||||
// @ts-ignore
|
||||
const defKeys = proc._def?.router ? Object.keys(proc._def.router) : [];
|
||||
logger.info(`Process router keys (direct): ${JSON.stringify(procKeys)}`);
|
||||
logger.info(`Process router keys (_def): ${JSON.stringify(defKeys)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.info('Could not stringify process router keys', e);
|
||||
}
|
||||
|
||||
const db = router.database;
|
||||
logger.info(`Database router check: ${!!db}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error logging router:', e);
|
||||
}
|
||||
|
||||
export const rpcHandler: RPCHandler<Record<never, never>> = new RPCHandler(router);
|
||||
+42
-42
@@ -1,42 +1,42 @@
|
||||
import { IPC_CHANNELS } from '@/constants';
|
||||
import { createORPCClient } from '@orpc/client';
|
||||
import { RPCLink } from '@orpc/client/message-port';
|
||||
import type { RouterClient } from '@orpc/server';
|
||||
import type { router } from './router';
|
||||
|
||||
type IPCClient = RouterClient<typeof router>;
|
||||
|
||||
class IPCManager {
|
||||
private readonly clientPort: MessagePort;
|
||||
private readonly serverPort: MessagePort;
|
||||
|
||||
public readonly client: IPCClient;
|
||||
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor() {
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
this.clientPort = port1;
|
||||
this.serverPort = port2;
|
||||
|
||||
this.client = createORPCClient<IPCClient>(
|
||||
new RPCLink({
|
||||
port: this.clientPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clientPort.start();
|
||||
|
||||
window.postMessage(IPC_CHANNELS.START_ORPC_SERVER, '*', [this.serverPort]);
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const ipc = new IPCManager();
|
||||
ipc.initialize();
|
||||
import { IPC_CHANNELS } from '@/constants';
|
||||
import { createORPCClient } from '@orpc/client';
|
||||
import { RPCLink } from '@orpc/client/message-port';
|
||||
import type { RouterClient } from '@orpc/server';
|
||||
import type { router } from './router';
|
||||
|
||||
type IPCClient = RouterClient<typeof router>;
|
||||
|
||||
class IPCManager {
|
||||
private readonly clientPort: MessagePort;
|
||||
private readonly serverPort: MessagePort;
|
||||
|
||||
public readonly client: IPCClient;
|
||||
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor() {
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
this.clientPort = port1;
|
||||
this.serverPort = port2;
|
||||
|
||||
this.client = createORPCClient<IPCClient>(
|
||||
new RPCLink({
|
||||
port: this.clientPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clientPort.start();
|
||||
|
||||
window.postMessage(IPC_CHANNELS.START_ORPC_SERVER, '*', [this.serverPort]);
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
export const ipc = new IPCManager();
|
||||
ipc.initialize();
|
||||
+65
-65
@@ -1,65 +1,65 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
bootstrapNestServer,
|
||||
isNestServerRunning,
|
||||
stopNestServer,
|
||||
} from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
|
||||
/**
|
||||
* Checks if the Gemini Nexus process is running.
|
||||
* Uses find-process package for robust cross-platform process detection.
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function isProcessRunning(): Promise<boolean> {
|
||||
// Check the embedded NestJS proxy server status instead of an external process
|
||||
return isNestServerRunning();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the Gemini Nexus process.
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function closeGeminiNexus(): Promise<void> {
|
||||
logger.info('Stopping Gemini Nexus proxy server...');
|
||||
await stopNestServer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Waits for the Gemini Nexus process to exit.
|
||||
* Polls the NestJS server status until it stops or the timeout is reached.
|
||||
* @param timeoutMs {number} Maximum time to wait in milliseconds.
|
||||
* @returns {Promise<void>} A promise that resolves when the process exits.
|
||||
*/
|
||||
export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (isNestServerRunning()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('Timeout waiting for Gemini Nexus proxy server to stop');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Gemini Nexus process.
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
|
||||
* @returns {Promise<void>} A promise that resolves when the process starts.
|
||||
*/
|
||||
export async function startGeminiNexus(_useUri = true): Promise<void> {
|
||||
logger.info('Starting Gemini Nexus proxy server...');
|
||||
|
||||
if (isNestServerRunning()) {
|
||||
logger.info('Gemini Nexus proxy server is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = ConfigManager.loadConfig();
|
||||
if (config.proxy) {
|
||||
await bootstrapNestServer(config.proxy);
|
||||
} else {
|
||||
throw new Error('Proxy configuration not found');
|
||||
}
|
||||
}
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
bootstrapNestServer,
|
||||
isNestServerRunning,
|
||||
stopNestServer,
|
||||
} from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
|
||||
/**
|
||||
* Checks if the Gemini Nexus process is running.
|
||||
* Uses find-process package for robust cross-platform process detection.
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function isProcessRunning(): Promise<boolean> {
|
||||
// Check the embedded NestJS proxy server status instead of an external process
|
||||
return isNestServerRunning();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the Gemini Nexus process.
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function closeGeminiNexus(): Promise<void> {
|
||||
logger.info('Stopping Gemini Nexus proxy server...');
|
||||
await stopNestServer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Waits for the Gemini Nexus process to exit.
|
||||
* Polls the NestJS server status until it stops or the timeout is reached.
|
||||
* @param timeoutMs {number} Maximum time to wait in milliseconds.
|
||||
* @returns {Promise<void>} A promise that resolves when the process exits.
|
||||
*/
|
||||
export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (isNestServerRunning()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error('Timeout waiting for Gemini Nexus proxy server to stop');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Gemini Nexus process.
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
|
||||
* @returns {Promise<void>} A promise that resolves when the process starts.
|
||||
*/
|
||||
export async function startGeminiNexus(_useUri = true): Promise<void> {
|
||||
logger.info('Starting Gemini Nexus proxy server...');
|
||||
|
||||
if (isNestServerRunning()) {
|
||||
logger.info('Gemini Nexus proxy server is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = ConfigManager.loadConfig();
|
||||
if (config.proxy) {
|
||||
await bootstrapNestServer(config.proxy);
|
||||
} else {
|
||||
throw new Error('Proxy configuration not found');
|
||||
}
|
||||
}
|
||||
+372
-372
@@ -1,372 +1,372 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getServiceOrThrow } from './service-registry';
|
||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
|
||||
export interface TrafficLogEntryParsed {
|
||||
timestamp: string;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeServiceError(error: unknown): string {
|
||||
if (error instanceof Error && error.message.includes('is not registered')) {
|
||||
return 'Proxy advanced service not initialized';
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function getRecentTrafficLogs(
|
||||
limit: number = 50,
|
||||
direction?: string,
|
||||
): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> {
|
||||
try {
|
||||
const rows = TrafficLogsRepo.getRecent(limit, direction);
|
||||
const entries: TrafficLogEntryParsed[] = rows.map((r) => ({
|
||||
timestamp: new Date(r.timestamp).toISOString(),
|
||||
direction: r.direction,
|
||||
requestId: r.requestId,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method,
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
body: r.body,
|
||||
chunk: r.chunk,
|
||||
durationMs: r.durationMs,
|
||||
error: r.error,
|
||||
metadata: r.metadata,
|
||||
}));
|
||||
|
||||
return { success: true, data: entries };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrafficLogStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const stats = TrafficLogsRepo.getStats();
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportTrafficLogs(
|
||||
format: 'json' | 'csv',
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
try {
|
||||
const rows = TrafficLogsRepo.getAllForExport();
|
||||
|
||||
if (format === 'json') {
|
||||
return { success: true, data: JSON.stringify(rows, null, 2) };
|
||||
}
|
||||
|
||||
// CSV format
|
||||
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
|
||||
const rowsCsv = rows.map((e) =>
|
||||
[
|
||||
new Date(e.timestamp).toISOString(),
|
||||
e.direction,
|
||||
e.requestId,
|
||||
e.endpoint,
|
||||
e.method || '',
|
||||
e.status != null ? e.status : '',
|
||||
e.durationMs != null ? e.durationMs : '',
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') };
|
||||
} catch (error) {
|
||||
logger.error('Failed to export traffic logs', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getParityCounters(): Promise<{
|
||||
success: boolean;
|
||||
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
return {
|
||||
success: true,
|
||||
data: tokenManager.getParityCounters(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCircuitBreakerStatus(): Promise<{
|
||||
success: boolean;
|
||||
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
|
||||
const rawStatus = tokenManager.getCircuitBreakerStatus();
|
||||
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
|
||||
for (const [accountId, entry] of Object.entries(rawStatus)) {
|
||||
states[accountId] = {
|
||||
state: entry.state,
|
||||
failures: entry.failureCount,
|
||||
lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
states,
|
||||
globalEnabled: true,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProxyMetrics(): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
uptimeSeconds: number;
|
||||
totalRequests: number;
|
||||
activeConnections: number;
|
||||
avgLatency: number;
|
||||
errorRate: number;
|
||||
requestsPerMinute: number;
|
||||
cacheHitRate: number;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyMetrics = getServiceOrThrow('proxyMetrics');
|
||||
|
||||
const metrics = proxyMetrics.getMetrics();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
totalRequests: metrics.totalRequests,
|
||||
activeConnections: metrics.activeConnections,
|
||||
avgLatency: metrics.avgLatency,
|
||||
errorRate: metrics.errorRate,
|
||||
requestsPerMinute: metrics.requestsPerMinute,
|
||||
cacheHitRate: metrics.cacheHitRate,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get proxy metrics', error);
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
return {
|
||||
success: true,
|
||||
data: proxyReplay.getRecentRequests(20),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayRequest(
|
||||
requestId: string,
|
||||
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
const result = await proxyReplay.replayRequest(requestId);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelCapabilityEntry {
|
||||
id: string;
|
||||
object: string;
|
||||
displayName?: string;
|
||||
capabilities: {
|
||||
vision: boolean;
|
||||
thinking: boolean;
|
||||
streaming: boolean;
|
||||
jsonMode: boolean;
|
||||
recommended: boolean;
|
||||
};
|
||||
limits: {
|
||||
maxTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
thinkingBudget?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(): Promise<{
|
||||
success: boolean;
|
||||
data?: ModelCapabilityEntry[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
|
||||
const modelMap = new Map<string, ModelCapabilityEntry>();
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account.quota || !account.quota.models) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [modelId, modelInfo] of Object.entries(account.quota.models)) {
|
||||
if (modelMap.has(modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
modelMap.set(modelId, {
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
displayName: modelInfo.display_name,
|
||||
capabilities: {
|
||||
vision: modelInfo.supports_images ?? false,
|
||||
thinking: modelInfo.supports_thinking ?? false,
|
||||
streaming: true,
|
||||
jsonMode: true,
|
||||
recommended: modelInfo.recommended ?? false,
|
||||
},
|
||||
limits: {
|
||||
maxTokens: modelInfo.max_tokens,
|
||||
maxOutputTokens: modelInfo.max_output_tokens,
|
||||
thinkingBudget: modelInfo.thinking_budget,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const data = Array.from(modelMap.values()).sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model capabilities', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function mapIdeConfigResult(
|
||||
result: IdeConfigResult,
|
||||
): {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
} {
|
||||
switch (result.ide) {
|
||||
case 'vscode':
|
||||
return {
|
||||
name: 'VS Code',
|
||||
settingsPath: 'settings.json',
|
||||
proxySetting: 'openai.url',
|
||||
apiKeySetting: 'openai.apiKey',
|
||||
instructions: 'Add these settings to your VS Code settings.json',
|
||||
};
|
||||
case 'cursor':
|
||||
return {
|
||||
name: 'Cursor',
|
||||
settingsPath: 'settings.json',
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Set these environment variables for Cursor',
|
||||
};
|
||||
case 'claude-code':
|
||||
return {
|
||||
name: 'Claude Code',
|
||||
settingsPath: 'Shell environment',
|
||||
proxySetting: 'ANTHROPIC_BASE_URL',
|
||||
apiKeySetting: 'ANTHROPIC_API_KEY',
|
||||
instructions: 'Run these export commands before starting Claude Code',
|
||||
};
|
||||
case 'jetbrains':
|
||||
return {
|
||||
name: 'JetBrains',
|
||||
settingsPath: 'IDE Settings',
|
||||
proxySetting: 'Base URL',
|
||||
apiKeySetting: 'API Key',
|
||||
instructions: String(result.content),
|
||||
};
|
||||
case 'opencide':
|
||||
return {
|
||||
name: 'Opencide',
|
||||
settingsPath: '.env',
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Add these lines to your .env file',
|
||||
};
|
||||
default: {
|
||||
const exhaustiveCheck: never = result.ide;
|
||||
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateIdeConfig(
|
||||
ide: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
|
||||
|
||||
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
|
||||
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
|
||||
if (!supportedIdes.includes(normalizedIde)) {
|
||||
return { success: false, error: `Unsupported IDE: ${ide}` };
|
||||
}
|
||||
|
||||
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
|
||||
if (!result) {
|
||||
return { success: false, error: 'Failed to generate IDE config: server not configured' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: mapIdeConfigResult(result),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getServiceOrThrow } from './service-registry';
|
||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
|
||||
export interface TrafficLogEntryParsed {
|
||||
timestamp: string;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeServiceError(error: unknown): string {
|
||||
if (error instanceof Error && error.message.includes('is not registered')) {
|
||||
return 'Proxy advanced service not initialized';
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function getRecentTrafficLogs(
|
||||
limit: number = 50,
|
||||
direction?: string,
|
||||
): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> {
|
||||
try {
|
||||
const rows = TrafficLogsRepo.getRecent(limit, direction);
|
||||
const entries: TrafficLogEntryParsed[] = rows.map((r) => ({
|
||||
timestamp: new Date(r.timestamp).toISOString(),
|
||||
direction: r.direction,
|
||||
requestId: r.requestId,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method,
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
body: r.body,
|
||||
chunk: r.chunk,
|
||||
durationMs: r.durationMs,
|
||||
error: r.error,
|
||||
metadata: r.metadata,
|
||||
}));
|
||||
|
||||
return { success: true, data: entries };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrafficLogStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const stats = TrafficLogsRepo.getStats();
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportTrafficLogs(
|
||||
format: 'json' | 'csv',
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
try {
|
||||
const rows = TrafficLogsRepo.getAllForExport();
|
||||
|
||||
if (format === 'json') {
|
||||
return { success: true, data: JSON.stringify(rows, null, 2) };
|
||||
}
|
||||
|
||||
// CSV format
|
||||
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
|
||||
const rowsCsv = rows.map((e) =>
|
||||
[
|
||||
new Date(e.timestamp).toISOString(),
|
||||
e.direction,
|
||||
e.requestId,
|
||||
e.endpoint,
|
||||
e.method || '',
|
||||
e.status != null ? e.status : '',
|
||||
e.durationMs != null ? e.durationMs : '',
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') };
|
||||
} catch (error) {
|
||||
logger.error('Failed to export traffic logs', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getParityCounters(): Promise<{
|
||||
success: boolean;
|
||||
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
return {
|
||||
success: true,
|
||||
data: tokenManager.getParityCounters(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCircuitBreakerStatus(): Promise<{
|
||||
success: boolean;
|
||||
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
|
||||
const rawStatus = tokenManager.getCircuitBreakerStatus();
|
||||
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
|
||||
for (const [accountId, entry] of Object.entries(rawStatus)) {
|
||||
states[accountId] = {
|
||||
state: entry.state,
|
||||
failures: entry.failureCount,
|
||||
lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
states,
|
||||
globalEnabled: true,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProxyMetrics(): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
uptimeSeconds: number;
|
||||
totalRequests: number;
|
||||
activeConnections: number;
|
||||
avgLatency: number;
|
||||
errorRate: number;
|
||||
requestsPerMinute: number;
|
||||
cacheHitRate: number;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyMetrics = getServiceOrThrow('proxyMetrics');
|
||||
|
||||
const metrics = proxyMetrics.getMetrics();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
totalRequests: metrics.totalRequests,
|
||||
activeConnections: metrics.activeConnections,
|
||||
avgLatency: metrics.avgLatency,
|
||||
errorRate: metrics.errorRate,
|
||||
requestsPerMinute: metrics.requestsPerMinute,
|
||||
cacheHitRate: metrics.cacheHitRate,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get proxy metrics', error);
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
return {
|
||||
success: true,
|
||||
data: proxyReplay.getRecentRequests(20),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayRequest(
|
||||
requestId: string,
|
||||
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
const result = await proxyReplay.replayRequest(requestId);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelCapabilityEntry {
|
||||
id: string;
|
||||
object: string;
|
||||
displayName?: string;
|
||||
capabilities: {
|
||||
vision: boolean;
|
||||
thinking: boolean;
|
||||
streaming: boolean;
|
||||
jsonMode: boolean;
|
||||
recommended: boolean;
|
||||
};
|
||||
limits: {
|
||||
maxTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
thinkingBudget?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(): Promise<{
|
||||
success: boolean;
|
||||
data?: ModelCapabilityEntry[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
|
||||
const modelMap = new Map<string, ModelCapabilityEntry>();
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account.quota || !account.quota.models) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [modelId, modelInfo] of Object.entries(account.quota.models)) {
|
||||
if (modelMap.has(modelId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
modelMap.set(modelId, {
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
displayName: modelInfo.display_name,
|
||||
capabilities: {
|
||||
vision: modelInfo.supports_images ?? false,
|
||||
thinking: modelInfo.supports_thinking ?? false,
|
||||
streaming: true,
|
||||
jsonMode: true,
|
||||
recommended: modelInfo.recommended ?? false,
|
||||
},
|
||||
limits: {
|
||||
maxTokens: modelInfo.max_tokens,
|
||||
maxOutputTokens: modelInfo.max_output_tokens,
|
||||
thinkingBudget: modelInfo.thinking_budget,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const data = Array.from(modelMap.values()).sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model capabilities', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function mapIdeConfigResult(
|
||||
result: IdeConfigResult,
|
||||
): {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
} {
|
||||
switch (result.ide) {
|
||||
case 'vscode':
|
||||
return {
|
||||
name: 'VS Code',
|
||||
settingsPath: 'settings.json',
|
||||
proxySetting: 'openai.url',
|
||||
apiKeySetting: 'openai.apiKey',
|
||||
instructions: 'Add these settings to your VS Code settings.json',
|
||||
};
|
||||
case 'cursor':
|
||||
return {
|
||||
name: 'Cursor',
|
||||
settingsPath: 'settings.json',
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Set these environment variables for Cursor',
|
||||
};
|
||||
case 'claude-code':
|
||||
return {
|
||||
name: 'Claude Code',
|
||||
settingsPath: 'Shell environment',
|
||||
proxySetting: 'ANTHROPIC_BASE_URL',
|
||||
apiKeySetting: 'ANTHROPIC_API_KEY',
|
||||
instructions: 'Run these export commands before starting Claude Code',
|
||||
};
|
||||
case 'jetbrains':
|
||||
return {
|
||||
name: 'JetBrains',
|
||||
settingsPath: 'IDE Settings',
|
||||
proxySetting: 'Base URL',
|
||||
apiKeySetting: 'API Key',
|
||||
instructions: String(result.content),
|
||||
};
|
||||
case 'opencide':
|
||||
return {
|
||||
name: 'Opencide',
|
||||
settingsPath: '.env',
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Add these lines to your .env file',
|
||||
};
|
||||
default: {
|
||||
const exhaustiveCheck: never = result.ide;
|
||||
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateIdeConfig(
|
||||
ide: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
|
||||
|
||||
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
|
||||
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
|
||||
if (!supportedIdes.includes(normalizedIde)) {
|
||||
return { success: false, error: `Unsupported IDE: ${ide}` };
|
||||
}
|
||||
|
||||
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
|
||||
if (!result) {
|
||||
return { success: false, error: 'Failed to generate IDE config: server not configured' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: mapIdeConfigResult(result),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: normalizeServiceError(error) };
|
||||
}
|
||||
}
|
||||
+223
-223
@@ -1,223 +1,223 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import {
|
||||
getRecentTrafficLogs,
|
||||
getTrafficLogStats,
|
||||
exportTrafficLogs,
|
||||
getParityCounters,
|
||||
getCircuitBreakerStatus,
|
||||
getProxyMetrics,
|
||||
getRecentRequests,
|
||||
generateIdeConfig,
|
||||
replayRequest,
|
||||
getModelCapabilities,
|
||||
} from './handler';
|
||||
|
||||
const TrafficLogEntrySchema = z.object({
|
||||
timestamp: z.string(),
|
||||
direction: z.string(),
|
||||
requestId: z.string(),
|
||||
endpoint: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
headers: z.record(z.string(), z.unknown()).optional(),
|
||||
body: z.unknown().optional(),
|
||||
chunk: z.unknown().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
error: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
stack: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
body: z.unknown().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const TrafficLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(TrafficLogEntrySchema).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const TrafficLogStatsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
byDirection: z.record(z.string(), z.number()),
|
||||
byEndpoint: z.record(z.string(), z.number()),
|
||||
total: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ExportTrafficLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ParityCountersResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
totalRequests: z.number(),
|
||||
matchedRequests: z.number(),
|
||||
parityViolations: z.number(),
|
||||
lastUpdated: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const CircuitBreakerStatusResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
states: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
state: z.string(),
|
||||
failures: z.number(),
|
||||
lastFailure: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
globalEnabled: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProxyMetricsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
uptimeSeconds: z.number(),
|
||||
totalRequests: z.number(),
|
||||
activeConnections: z.number(),
|
||||
avgLatency: z.number(),
|
||||
errorRate: z.number(),
|
||||
requestsPerMinute: z.number(),
|
||||
cacheHitRate: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const RecentRequestsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(z.unknown()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ReplayRequestResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
original: z.unknown(),
|
||||
newResponse: z.unknown(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ModelCapabilitiesResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
capabilities: z.object({
|
||||
vision: z.boolean(),
|
||||
thinking: z.boolean(),
|
||||
streaming: z.boolean(),
|
||||
jsonMode: z.boolean(),
|
||||
recommended: z.boolean(),
|
||||
}),
|
||||
limits: z.object({
|
||||
maxTokens: z.number().optional(),
|
||||
maxOutputTokens: z.number().optional(),
|
||||
thinkingBudget: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const IdeConfigResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
settingsPath: z.string(),
|
||||
proxySetting: z.string(),
|
||||
apiKeySetting: z.string(),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const proxyAdvancedRouter = os.router({
|
||||
getRecentTrafficLogs: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().int().min(1).max(1000).optional(),
|
||||
direction: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.output(TrafficLogsResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getRecentTrafficLogs(input.limit, input.direction);
|
||||
}),
|
||||
|
||||
getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => {
|
||||
return getTrafficLogStats();
|
||||
}),
|
||||
|
||||
exportTrafficLogs: os
|
||||
.input(z.object({ format: z.enum(['json', 'csv']) }))
|
||||
.output(ExportTrafficLogsResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return exportTrafficLogs(input.format);
|
||||
}),
|
||||
|
||||
getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => {
|
||||
return getParityCounters();
|
||||
}),
|
||||
|
||||
getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => {
|
||||
return getCircuitBreakerStatus();
|
||||
}),
|
||||
|
||||
getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => {
|
||||
return getProxyMetrics();
|
||||
}),
|
||||
|
||||
getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => {
|
||||
return getRecentRequests();
|
||||
}),
|
||||
|
||||
generateIdeConfig: os
|
||||
.input(z.object({ ide: z.string() }))
|
||||
.output(IdeConfigResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return generateIdeConfig(input.ide);
|
||||
}),
|
||||
|
||||
replayRequest: os
|
||||
.input(z.object({ requestId: z.string() }))
|
||||
.output(ReplayRequestResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return replayRequest(input.requestId);
|
||||
}),
|
||||
|
||||
getModelCapabilities: os.output(ModelCapabilitiesResponseSchema).handler(async () => {
|
||||
return getModelCapabilities();
|
||||
}),
|
||||
});
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import {
|
||||
getRecentTrafficLogs,
|
||||
getTrafficLogStats,
|
||||
exportTrafficLogs,
|
||||
getParityCounters,
|
||||
getCircuitBreakerStatus,
|
||||
getProxyMetrics,
|
||||
getRecentRequests,
|
||||
generateIdeConfig,
|
||||
replayRequest,
|
||||
getModelCapabilities,
|
||||
} from './handler';
|
||||
|
||||
const TrafficLogEntrySchema = z.object({
|
||||
timestamp: z.string(),
|
||||
direction: z.string(),
|
||||
requestId: z.string(),
|
||||
endpoint: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
headers: z.record(z.string(), z.unknown()).optional(),
|
||||
body: z.unknown().optional(),
|
||||
chunk: z.unknown().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
error: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
stack: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
body: z.unknown().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const TrafficLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(TrafficLogEntrySchema).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const TrafficLogStatsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
byDirection: z.record(z.string(), z.number()),
|
||||
byEndpoint: z.record(z.string(), z.number()),
|
||||
total: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ExportTrafficLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ParityCountersResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
totalRequests: z.number(),
|
||||
matchedRequests: z.number(),
|
||||
parityViolations: z.number(),
|
||||
lastUpdated: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const CircuitBreakerStatusResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
states: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
state: z.string(),
|
||||
failures: z.number(),
|
||||
lastFailure: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
globalEnabled: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProxyMetricsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
uptimeSeconds: z.number(),
|
||||
totalRequests: z.number(),
|
||||
activeConnections: z.number(),
|
||||
avgLatency: z.number(),
|
||||
errorRate: z.number(),
|
||||
requestsPerMinute: z.number(),
|
||||
cacheHitRate: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const RecentRequestsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(z.unknown()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ReplayRequestResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
original: z.unknown(),
|
||||
newResponse: z.unknown(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ModelCapabilitiesResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
capabilities: z.object({
|
||||
vision: z.boolean(),
|
||||
thinking: z.boolean(),
|
||||
streaming: z.boolean(),
|
||||
jsonMode: z.boolean(),
|
||||
recommended: z.boolean(),
|
||||
}),
|
||||
limits: z.object({
|
||||
maxTokens: z.number().optional(),
|
||||
maxOutputTokens: z.number().optional(),
|
||||
thinkingBudget: z.number().optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const IdeConfigResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
settingsPath: z.string(),
|
||||
proxySetting: z.string(),
|
||||
apiKeySetting: z.string(),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const proxyAdvancedRouter = os.router({
|
||||
getRecentTrafficLogs: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().int().min(1).max(1000).optional(),
|
||||
direction: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.output(TrafficLogsResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getRecentTrafficLogs(input.limit, input.direction);
|
||||
}),
|
||||
|
||||
getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => {
|
||||
return getTrafficLogStats();
|
||||
}),
|
||||
|
||||
exportTrafficLogs: os
|
||||
.input(z.object({ format: z.enum(['json', 'csv']) }))
|
||||
.output(ExportTrafficLogsResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return exportTrafficLogs(input.format);
|
||||
}),
|
||||
|
||||
getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => {
|
||||
return getParityCounters();
|
||||
}),
|
||||
|
||||
getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => {
|
||||
return getCircuitBreakerStatus();
|
||||
}),
|
||||
|
||||
getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => {
|
||||
return getProxyMetrics();
|
||||
}),
|
||||
|
||||
getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => {
|
||||
return getRecentRequests();
|
||||
}),
|
||||
|
||||
generateIdeConfig: os
|
||||
.input(z.object({ ide: z.string() }))
|
||||
.output(IdeConfigResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return generateIdeConfig(input.ide);
|
||||
}),
|
||||
|
||||
replayRequest: os
|
||||
.input(z.object({ requestId: z.string() }))
|
||||
.output(ReplayRequestResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return replayRequest(input.requestId);
|
||||
}),
|
||||
|
||||
getModelCapabilities: os.output(ModelCapabilitiesResponseSchema).handler(async () => {
|
||||
return getModelCapabilities();
|
||||
}),
|
||||
});
|
||||
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* Service registry for proxy-advanced IPC handlers to access
|
||||
* NestJS service instances without DI coupling.
|
||||
*
|
||||
* Services auto-register on construction.
|
||||
*/
|
||||
import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||
import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service';
|
||||
import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service';
|
||||
import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
|
||||
interface ProxyAdvancedRegistry {
|
||||
tokenManager: TokenManagerService | null;
|
||||
proxyMetrics: ProxyMetricsService | null;
|
||||
proxyReplay: ProxyReplayService | null;
|
||||
proxyIdeConfig: ProxyIdeConfigService | null;
|
||||
}
|
||||
|
||||
export const proxyAdvancedRegistry: ProxyAdvancedRegistry = {
|
||||
tokenManager: null,
|
||||
proxyMetrics: null,
|
||||
proxyReplay: null,
|
||||
proxyIdeConfig: null,
|
||||
};
|
||||
|
||||
export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
instance: ProxyAdvancedRegistry[K],
|
||||
): void {
|
||||
proxyAdvancedRegistry[key] = instance;
|
||||
}
|
||||
|
||||
export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
): NonNullable<ProxyAdvancedRegistry[K]> {
|
||||
const svc = proxyAdvancedRegistry[key];
|
||||
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
|
||||
return svc;
|
||||
}
|
||||
/**
|
||||
* Service registry for proxy-advanced IPC handlers to access
|
||||
* NestJS service instances without DI coupling.
|
||||
*
|
||||
* Services auto-register on construction.
|
||||
*/
|
||||
import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||
import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service';
|
||||
import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service';
|
||||
import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
|
||||
interface ProxyAdvancedRegistry {
|
||||
tokenManager: TokenManagerService | null;
|
||||
proxyMetrics: ProxyMetricsService | null;
|
||||
proxyReplay: ProxyReplayService | null;
|
||||
proxyIdeConfig: ProxyIdeConfigService | null;
|
||||
}
|
||||
|
||||
export const proxyAdvancedRegistry: ProxyAdvancedRegistry = {
|
||||
tokenManager: null,
|
||||
proxyMetrics: null,
|
||||
proxyReplay: null,
|
||||
proxyIdeConfig: null,
|
||||
};
|
||||
|
||||
export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
instance: ProxyAdvancedRegistry[K],
|
||||
): void {
|
||||
proxyAdvancedRegistry[key] = instance;
|
||||
}
|
||||
|
||||
export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
): NonNullable<ProxyAdvancedRegistry[K]> {
|
||||
const svc = proxyAdvancedRegistry[key];
|
||||
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
|
||||
return svc;
|
||||
}
|
||||
+61
-61
@@ -1,61 +1,61 @@
|
||||
import { app } from './app';
|
||||
import { theme } from './theme';
|
||||
import { window } from './window';
|
||||
import { databaseRouter } from './database/router';
|
||||
import { accountRouter } from './account/router';
|
||||
import { cloudRouter } from './cloud/router';
|
||||
import { configRouter } from './config/router';
|
||||
import { gatewayRouter } from './gateway/router';
|
||||
import { usageRouter } from './usage/router';
|
||||
import { proxyAdvancedRouter } from './proxy-advanced/router';
|
||||
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { isProcessRunning, closeGeminiNexus, startGeminiNexus } from './process/handler';
|
||||
import { systemHandler } from './system/handler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Log middleware setup
|
||||
const logMiddleware = os.middleware(async (opts: any) => {
|
||||
const { next, path, meta } = opts;
|
||||
const requestPath = path || meta?.path || 'unknown';
|
||||
|
||||
try {
|
||||
const result = await next({});
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.error(`[ORPC] Error in handler for ${JSON.stringify(requestPath)}:`, err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit Router Definition
|
||||
export const router = os.use(logMiddleware).router({
|
||||
ping: os.output(z.string()).handler(async () => 'pong'),
|
||||
|
||||
theme,
|
||||
window,
|
||||
app,
|
||||
database: databaseRouter,
|
||||
|
||||
// Inline process router to ensure structure
|
||||
proc: os.router({
|
||||
isProcessRunning: os.output(z.boolean()).handler(async () => {
|
||||
return await isProcessRunning();
|
||||
}),
|
||||
closeGeminiNexus: os.output(z.void()).handler(async () => {
|
||||
await closeGeminiNexus();
|
||||
}),
|
||||
startGeminiNexus: os.output(z.void()).handler(async () => {
|
||||
await startGeminiNexus();
|
||||
}),
|
||||
}),
|
||||
|
||||
account: accountRouter,
|
||||
cloud: cloudRouter,
|
||||
config: configRouter,
|
||||
gateway: gatewayRouter,
|
||||
usage: usageRouter,
|
||||
proxyAdvanced: proxyAdvancedRouter,
|
||||
system: systemHandler,
|
||||
});
|
||||
import { app } from './app';
|
||||
import { theme } from './theme';
|
||||
import { window } from './window';
|
||||
import { databaseRouter } from './database/router';
|
||||
import { accountRouter } from './account/router';
|
||||
import { cloudRouter } from './cloud/router';
|
||||
import { configRouter } from './config/router';
|
||||
import { gatewayRouter } from './gateway/router';
|
||||
import { usageRouter } from './usage/router';
|
||||
import { proxyAdvancedRouter } from './proxy-advanced/router';
|
||||
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { isProcessRunning, closeGeminiNexus, startGeminiNexus } from './process/handler';
|
||||
import { systemHandler } from './system/handler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Log middleware setup
|
||||
const logMiddleware = os.middleware(async (opts: any) => {
|
||||
const { next, path, meta } = opts;
|
||||
const requestPath = path || meta?.path || 'unknown';
|
||||
|
||||
try {
|
||||
const result = await next({});
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.error(`[ORPC] Error in handler for ${JSON.stringify(requestPath)}:`, err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit Router Definition
|
||||
export const router = os.use(logMiddleware).router({
|
||||
ping: os.output(z.string()).handler(async () => 'pong'),
|
||||
|
||||
theme,
|
||||
window,
|
||||
app,
|
||||
database: databaseRouter,
|
||||
|
||||
// Inline process router to ensure structure
|
||||
proc: os.router({
|
||||
isProcessRunning: os.output(z.boolean()).handler(async () => {
|
||||
return await isProcessRunning();
|
||||
}),
|
||||
closeGeminiNexus: os.output(z.void()).handler(async () => {
|
||||
await closeGeminiNexus();
|
||||
}),
|
||||
startGeminiNexus: os.output(z.void()).handler(async () => {
|
||||
await startGeminiNexus();
|
||||
}),
|
||||
}),
|
||||
|
||||
account: accountRouter,
|
||||
cloud: cloudRouter,
|
||||
config: configRouter,
|
||||
gateway: gatewayRouter,
|
||||
usage: usageRouter,
|
||||
proxyAdvanced: proxyAdvancedRouter,
|
||||
system: systemHandler,
|
||||
});
|
||||
+89
-89
@@ -1,89 +1,89 @@
|
||||
import { type DeviceProfile } from '../types/account';
|
||||
import { logger } from '../utils/logger';
|
||||
import { closeGeminiNexus, startGeminiNexus, _waitForProcessExit } from './process/handler';
|
||||
import { applyDeviceProfile } from './device/handler';
|
||||
import {
|
||||
type SwitchFailureReason,
|
||||
recordSwitchFailure,
|
||||
recordSwitchSuccess,
|
||||
} from './switchMetrics';
|
||||
|
||||
export interface SwitchFlowOptions {
|
||||
scope: 'local' | 'cloud';
|
||||
targetProfile: DeviceProfile | null;
|
||||
applyFingerprint: boolean;
|
||||
processExitTimeoutMs: number;
|
||||
performSwitch: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function toSwitchFailureReason(stage: string, error: unknown): SwitchFailureReason {
|
||||
if (stage === 'close') {
|
||||
return 'process_close_failed';
|
||||
}
|
||||
if (stage === 'missing_profile') {
|
||||
return 'missing_bound_profile';
|
||||
}
|
||||
if (stage === 'apply') {
|
||||
return 'apply_device_profile_failed';
|
||||
}
|
||||
if (stage === 'switch') {
|
||||
return 'perform_switch_failed';
|
||||
}
|
||||
if (stage === 'start') {
|
||||
return 'start_process_failed';
|
||||
}
|
||||
|
||||
// Keep legacy compatibility with reason encoded in thrown errors.
|
||||
if (error instanceof Error && error.message.includes('missing bound device profile')) {
|
||||
return 'missing_bound_profile';
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('device_apply_failed')) {
|
||||
return 'apply_device_profile_failed';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<void> {
|
||||
const { scope, targetProfile, applyFingerprint, processExitTimeoutMs, performSwitch } = options;
|
||||
|
||||
let stage = 'close';
|
||||
try {
|
||||
await closeGeminiNexus();
|
||||
try {
|
||||
await _waitForProcessExit(processExitTimeoutMs);
|
||||
} catch (error) {
|
||||
logger.warn('Process did not exit cleanly within timeout, but proceeding...', error);
|
||||
}
|
||||
|
||||
stage = 'apply';
|
||||
if (applyFingerprint) {
|
||||
if (!targetProfile) {
|
||||
stage = 'missing_profile';
|
||||
throw new Error('Account has no bound identity profile');
|
||||
}
|
||||
applyDeviceProfile(targetProfile);
|
||||
} else {
|
||||
logger.warn(
|
||||
'Identity profile apply is disabled by CRACK_IDENTITY_PROFILE_APPLY_ENABLED / CRACK_DEVICE_FINGERPRINT_ENABLED',
|
||||
);
|
||||
}
|
||||
|
||||
stage = 'switch';
|
||||
await performSwitch();
|
||||
stage = 'start';
|
||||
await startGeminiNexus();
|
||||
recordSwitchSuccess(scope);
|
||||
} catch (error) {
|
||||
const reason = toSwitchFailureReason(stage, error);
|
||||
const message = getErrorMessage(error);
|
||||
recordSwitchFailure(scope, reason, message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
import { type DeviceProfile } from '../types/account';
|
||||
import { logger } from '../utils/logger';
|
||||
import { closeGeminiNexus, startGeminiNexus, _waitForProcessExit } from './process/handler';
|
||||
import { applyDeviceProfile } from './device/handler';
|
||||
import {
|
||||
type SwitchFailureReason,
|
||||
recordSwitchFailure,
|
||||
recordSwitchSuccess,
|
||||
} from './switchMetrics';
|
||||
|
||||
export interface SwitchFlowOptions {
|
||||
scope: 'local' | 'cloud';
|
||||
targetProfile: DeviceProfile | null;
|
||||
applyFingerprint: boolean;
|
||||
processExitTimeoutMs: number;
|
||||
performSwitch: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function toSwitchFailureReason(stage: string, error: unknown): SwitchFailureReason {
|
||||
if (stage === 'close') {
|
||||
return 'process_close_failed';
|
||||
}
|
||||
if (stage === 'missing_profile') {
|
||||
return 'missing_bound_profile';
|
||||
}
|
||||
if (stage === 'apply') {
|
||||
return 'apply_device_profile_failed';
|
||||
}
|
||||
if (stage === 'switch') {
|
||||
return 'perform_switch_failed';
|
||||
}
|
||||
if (stage === 'start') {
|
||||
return 'start_process_failed';
|
||||
}
|
||||
|
||||
// Keep legacy compatibility with reason encoded in thrown errors.
|
||||
if (error instanceof Error && error.message.includes('missing bound device profile')) {
|
||||
return 'missing_bound_profile';
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('device_apply_failed')) {
|
||||
return 'apply_device_profile_failed';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<void> {
|
||||
const { scope, targetProfile, applyFingerprint, processExitTimeoutMs, performSwitch } = options;
|
||||
|
||||
let stage = 'close';
|
||||
try {
|
||||
await closeGeminiNexus();
|
||||
try {
|
||||
await _waitForProcessExit(processExitTimeoutMs);
|
||||
} catch (error) {
|
||||
logger.warn('Process did not exit cleanly within timeout, but proceeding...', error);
|
||||
}
|
||||
|
||||
stage = 'apply';
|
||||
if (applyFingerprint) {
|
||||
if (!targetProfile) {
|
||||
stage = 'missing_profile';
|
||||
throw new Error('Account has no bound identity profile');
|
||||
}
|
||||
applyDeviceProfile(targetProfile);
|
||||
} else {
|
||||
logger.warn(
|
||||
'Identity profile apply is disabled by CRACK_IDENTITY_PROFILE_APPLY_ENABLED / CRACK_DEVICE_FINGERPRINT_ENABLED',
|
||||
);
|
||||
}
|
||||
|
||||
stage = 'switch';
|
||||
await performSwitch();
|
||||
stage = 'start';
|
||||
await startGeminiNexus();
|
||||
recordSwitchSuccess(scope);
|
||||
} catch (error) {
|
||||
const reason = toSwitchFailureReason(stage, error);
|
||||
const message = getErrorMessage(error);
|
||||
recordSwitchFailure(scope, reason, message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+80
-80
@@ -1,80 +1,80 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type SwitchOwner = 'local-account-switch' | 'cloud-account-switch';
|
||||
|
||||
interface SwitchTask {
|
||||
owner: SwitchOwner;
|
||||
action: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
let activeSwitchOwner: SwitchOwner | null = null;
|
||||
const pendingQueue: SwitchTask[] = [];
|
||||
let processing = false;
|
||||
|
||||
function processQueue(): void {
|
||||
if (processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTask = pendingQueue.shift();
|
||||
if (!nextTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
processing = true;
|
||||
activeSwitchOwner = nextTask.owner;
|
||||
logger.info(`Acquired switch guard: ${nextTask.owner}`);
|
||||
|
||||
Promise.resolve()
|
||||
.then(nextTask.action)
|
||||
.then((result) => {
|
||||
nextTask.resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
nextTask.reject(error);
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info(`Released switch guard: ${nextTask.owner}`);
|
||||
activeSwitchOwner = null;
|
||||
processing = false;
|
||||
processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWithSwitchGuard<T>(
|
||||
owner: SwitchOwner,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
pendingQueue.push({
|
||||
owner,
|
||||
action: async () => await action(),
|
||||
resolve: (value) => {
|
||||
resolve(value as T);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
logger.info(
|
||||
`Queued switch request: ${owner} (active=${activeSwitchOwner || 'none'}, pending=${pendingQueue.length})`,
|
||||
);
|
||||
processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveSwitchOwner(): SwitchOwner | null {
|
||||
return activeSwitchOwner;
|
||||
}
|
||||
|
||||
export function getSwitchGuardSnapshot(): {
|
||||
activeOwner: SwitchOwner | null;
|
||||
pendingOwners: SwitchOwner[];
|
||||
pendingCount: number;
|
||||
} {
|
||||
return {
|
||||
activeOwner: activeSwitchOwner,
|
||||
pendingOwners: pendingQueue.map((item) => item.owner),
|
||||
pendingCount: pendingQueue.length,
|
||||
};
|
||||
}
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type SwitchOwner = 'local-account-switch' | 'cloud-account-switch';
|
||||
|
||||
interface SwitchTask {
|
||||
owner: SwitchOwner;
|
||||
action: () => Promise<unknown>;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
let activeSwitchOwner: SwitchOwner | null = null;
|
||||
const pendingQueue: SwitchTask[] = [];
|
||||
let processing = false;
|
||||
|
||||
function processQueue(): void {
|
||||
if (processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTask = pendingQueue.shift();
|
||||
if (!nextTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
processing = true;
|
||||
activeSwitchOwner = nextTask.owner;
|
||||
logger.info(`Acquired switch guard: ${nextTask.owner}`);
|
||||
|
||||
Promise.resolve()
|
||||
.then(nextTask.action)
|
||||
.then((result) => {
|
||||
nextTask.resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
nextTask.reject(error);
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info(`Released switch guard: ${nextTask.owner}`);
|
||||
activeSwitchOwner = null;
|
||||
processing = false;
|
||||
processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWithSwitchGuard<T>(
|
||||
owner: SwitchOwner,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
pendingQueue.push({
|
||||
owner,
|
||||
action: async () => await action(),
|
||||
resolve: (value) => {
|
||||
resolve(value as T);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
logger.info(
|
||||
`Queued switch request: ${owner} (active=${activeSwitchOwner || 'none'}, pending=${pendingQueue.length})`,
|
||||
);
|
||||
processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveSwitchOwner(): SwitchOwner | null {
|
||||
return activeSwitchOwner;
|
||||
}
|
||||
|
||||
export function getSwitchGuardSnapshot(): {
|
||||
activeOwner: SwitchOwner | null;
|
||||
pendingOwners: SwitchOwner[];
|
||||
pendingCount: number;
|
||||
} {
|
||||
return {
|
||||
activeOwner: activeSwitchOwner,
|
||||
pendingOwners: pendingQueue.map((item) => item.owner),
|
||||
pendingCount: pendingQueue.length,
|
||||
};
|
||||
}
|
||||
+113
-113
@@ -1,113 +1,113 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type SwitchScope = 'local' | 'cloud';
|
||||
|
||||
export type SwitchFailureReason =
|
||||
| 'unknown'
|
||||
| 'process_close_failed'
|
||||
| 'missing_bound_profile'
|
||||
| 'apply_device_profile_failed'
|
||||
| 'perform_switch_failed'
|
||||
| 'start_process_failed';
|
||||
|
||||
interface SwitchFailureMetadata {
|
||||
reason: SwitchFailureReason;
|
||||
message: string;
|
||||
occurredAt: number;
|
||||
}
|
||||
|
||||
interface SwitchMetricBucket {
|
||||
switchSuccess: number;
|
||||
switchFailure: number;
|
||||
rollbackAttempt: number;
|
||||
rollbackSuccess: number;
|
||||
rollbackFailure: number;
|
||||
failureReasons: Record<SwitchFailureReason, number>;
|
||||
lastFailure: SwitchFailureMetadata | null;
|
||||
}
|
||||
|
||||
type SwitchMetricMap = Record<SwitchScope, SwitchMetricBucket>;
|
||||
|
||||
const metrics: SwitchMetricMap = {
|
||||
local: {
|
||||
switchSuccess: 0,
|
||||
switchFailure: 0,
|
||||
rollbackAttempt: 0,
|
||||
rollbackSuccess: 0,
|
||||
rollbackFailure: 0,
|
||||
failureReasons: {
|
||||
unknown: 0,
|
||||
process_close_failed: 0,
|
||||
missing_bound_profile: 0,
|
||||
apply_device_profile_failed: 0,
|
||||
perform_switch_failed: 0,
|
||||
start_process_failed: 0,
|
||||
},
|
||||
lastFailure: null,
|
||||
},
|
||||
cloud: {
|
||||
switchSuccess: 0,
|
||||
switchFailure: 0,
|
||||
rollbackAttempt: 0,
|
||||
rollbackSuccess: 0,
|
||||
rollbackFailure: 0,
|
||||
failureReasons: {
|
||||
unknown: 0,
|
||||
process_close_failed: 0,
|
||||
missing_bound_profile: 0,
|
||||
apply_device_profile_failed: 0,
|
||||
perform_switch_failed: 0,
|
||||
start_process_failed: 0,
|
||||
},
|
||||
lastFailure: null,
|
||||
},
|
||||
};
|
||||
|
||||
function logSnapshot(scope: SwitchScope, reason: string): void {
|
||||
logger.info(`[switch-metrics] ${scope} ${reason}`, metrics[scope]);
|
||||
}
|
||||
|
||||
export function recordSwitchSuccess(scope: SwitchScope): void {
|
||||
metrics[scope].switchSuccess += 1;
|
||||
logSnapshot(scope, 'switch_success');
|
||||
}
|
||||
|
||||
export function recordSwitchFailure(
|
||||
scope: SwitchScope,
|
||||
reason: SwitchFailureReason = 'unknown',
|
||||
message: string = '',
|
||||
): void {
|
||||
metrics[scope].switchFailure += 1;
|
||||
metrics[scope].failureReasons[reason] += 1;
|
||||
metrics[scope].lastFailure = {
|
||||
reason,
|
||||
message,
|
||||
occurredAt: Date.now(),
|
||||
};
|
||||
logSnapshot(scope, 'switch_failure');
|
||||
}
|
||||
|
||||
export function recordSwitchRollback(scope: SwitchScope, success: boolean): void {
|
||||
metrics[scope].rollbackAttempt += 1;
|
||||
if (success) {
|
||||
metrics[scope].rollbackSuccess += 1;
|
||||
} else {
|
||||
metrics[scope].rollbackFailure += 1;
|
||||
}
|
||||
logSnapshot(scope, success ? 'rollback_success' : 'rollback_failure');
|
||||
}
|
||||
|
||||
export function getSwitchMetricsSnapshot(): SwitchMetricMap {
|
||||
return {
|
||||
local: {
|
||||
...metrics.local,
|
||||
failureReasons: { ...metrics.local.failureReasons },
|
||||
lastFailure: metrics.local.lastFailure ? { ...metrics.local.lastFailure } : null,
|
||||
},
|
||||
cloud: {
|
||||
...metrics.cloud,
|
||||
failureReasons: { ...metrics.cloud.failureReasons },
|
||||
lastFailure: metrics.cloud.lastFailure ? { ...metrics.cloud.lastFailure } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type SwitchScope = 'local' | 'cloud';
|
||||
|
||||
export type SwitchFailureReason =
|
||||
| 'unknown'
|
||||
| 'process_close_failed'
|
||||
| 'missing_bound_profile'
|
||||
| 'apply_device_profile_failed'
|
||||
| 'perform_switch_failed'
|
||||
| 'start_process_failed';
|
||||
|
||||
interface SwitchFailureMetadata {
|
||||
reason: SwitchFailureReason;
|
||||
message: string;
|
||||
occurredAt: number;
|
||||
}
|
||||
|
||||
interface SwitchMetricBucket {
|
||||
switchSuccess: number;
|
||||
switchFailure: number;
|
||||
rollbackAttempt: number;
|
||||
rollbackSuccess: number;
|
||||
rollbackFailure: number;
|
||||
failureReasons: Record<SwitchFailureReason, number>;
|
||||
lastFailure: SwitchFailureMetadata | null;
|
||||
}
|
||||
|
||||
type SwitchMetricMap = Record<SwitchScope, SwitchMetricBucket>;
|
||||
|
||||
const metrics: SwitchMetricMap = {
|
||||
local: {
|
||||
switchSuccess: 0,
|
||||
switchFailure: 0,
|
||||
rollbackAttempt: 0,
|
||||
rollbackSuccess: 0,
|
||||
rollbackFailure: 0,
|
||||
failureReasons: {
|
||||
unknown: 0,
|
||||
process_close_failed: 0,
|
||||
missing_bound_profile: 0,
|
||||
apply_device_profile_failed: 0,
|
||||
perform_switch_failed: 0,
|
||||
start_process_failed: 0,
|
||||
},
|
||||
lastFailure: null,
|
||||
},
|
||||
cloud: {
|
||||
switchSuccess: 0,
|
||||
switchFailure: 0,
|
||||
rollbackAttempt: 0,
|
||||
rollbackSuccess: 0,
|
||||
rollbackFailure: 0,
|
||||
failureReasons: {
|
||||
unknown: 0,
|
||||
process_close_failed: 0,
|
||||
missing_bound_profile: 0,
|
||||
apply_device_profile_failed: 0,
|
||||
perform_switch_failed: 0,
|
||||
start_process_failed: 0,
|
||||
},
|
||||
lastFailure: null,
|
||||
},
|
||||
};
|
||||
|
||||
function logSnapshot(scope: SwitchScope, reason: string): void {
|
||||
logger.info(`[switch-metrics] ${scope} ${reason}`, metrics[scope]);
|
||||
}
|
||||
|
||||
export function recordSwitchSuccess(scope: SwitchScope): void {
|
||||
metrics[scope].switchSuccess += 1;
|
||||
logSnapshot(scope, 'switch_success');
|
||||
}
|
||||
|
||||
export function recordSwitchFailure(
|
||||
scope: SwitchScope,
|
||||
reason: SwitchFailureReason = 'unknown',
|
||||
message: string = '',
|
||||
): void {
|
||||
metrics[scope].switchFailure += 1;
|
||||
metrics[scope].failureReasons[reason] += 1;
|
||||
metrics[scope].lastFailure = {
|
||||
reason,
|
||||
message,
|
||||
occurredAt: Date.now(),
|
||||
};
|
||||
logSnapshot(scope, 'switch_failure');
|
||||
}
|
||||
|
||||
export function recordSwitchRollback(scope: SwitchScope, success: boolean): void {
|
||||
metrics[scope].rollbackAttempt += 1;
|
||||
if (success) {
|
||||
metrics[scope].rollbackSuccess += 1;
|
||||
} else {
|
||||
metrics[scope].rollbackFailure += 1;
|
||||
}
|
||||
logSnapshot(scope, success ? 'rollback_success' : 'rollback_failure');
|
||||
}
|
||||
|
||||
export function getSwitchMetricsSnapshot(): SwitchMetricMap {
|
||||
return {
|
||||
local: {
|
||||
...metrics.local,
|
||||
failureReasons: { ...metrics.local.failureReasons },
|
||||
lastFailure: metrics.local.lastFailure ? { ...metrics.local.lastFailure } : null,
|
||||
},
|
||||
cloud: {
|
||||
...metrics.cloud,
|
||||
failureReasons: { ...metrics.cloud.failureReasons },
|
||||
lastFailure: metrics.cloud.lastFailure ? { ...metrics.cloud.lastFailure } : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
+72
-72
@@ -1,72 +1,72 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { networkInterfaces } from 'os';
|
||||
import { shell } from 'electron';
|
||||
import { getAgentDir } from '../../utils/paths';
|
||||
|
||||
// Schema for IP info
|
||||
const IpInfoSchema = z.object({
|
||||
address: z.string(),
|
||||
name: z.string(),
|
||||
isRecommended: z.boolean(),
|
||||
});
|
||||
|
||||
export const systemHandler = os.router({
|
||||
// Get all available local IPs with their adapter names
|
||||
get_local_ips: os.output(z.array(IpInfoSchema)).handler(async () => {
|
||||
const nets = networkInterfaces();
|
||||
const results: { address: string; name: string; isRecommended: boolean }[] = [];
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
const family = net.family;
|
||||
const isIPv4 = family === 'IPv4';
|
||||
|
||||
if (!isIPv4 || net.internal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const addr = net.address;
|
||||
|
||||
// Skip non-LAN addresses
|
||||
if (addr.startsWith('169.254.')) continue; // APIPA
|
||||
if (addr.startsWith('198.18.')) continue; // CGNAT / VPN
|
||||
|
||||
// Determine if this is a recommended (likely real LAN) address
|
||||
let isRecommended = false;
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
if (addr.startsWith('192.168.') || addr.startsWith('10.')) {
|
||||
if (
|
||||
lowerName.includes('wlan') ||
|
||||
lowerName.includes('wi-fi') ||
|
||||
lowerName.includes('wireless') ||
|
||||
lowerName === '以太网' ||
|
||||
lowerName === 'ethernet' ||
|
||||
lowerName.match(/^eth\d/)
|
||||
) {
|
||||
isRecommended = true;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ address: addr, name, isRecommended });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: recommended first, then by address
|
||||
results.sort((a, b) => {
|
||||
if (a.isRecommended !== b.isRecommended) {
|
||||
return b.isRecommended ? 1 : -1;
|
||||
}
|
||||
return a.address.localeCompare(b.address);
|
||||
});
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
// Open log directory in file explorer
|
||||
openLogDirectory: os.output(z.void()).handler(async () => {
|
||||
const logDir = getAgentDir();
|
||||
await shell.openPath(logDir);
|
||||
}),
|
||||
});
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { networkInterfaces } from 'os';
|
||||
import { shell } from 'electron';
|
||||
import { getAgentDir } from '../../utils/paths';
|
||||
|
||||
// Schema for IP info
|
||||
const IpInfoSchema = z.object({
|
||||
address: z.string(),
|
||||
name: z.string(),
|
||||
isRecommended: z.boolean(),
|
||||
});
|
||||
|
||||
export const systemHandler = os.router({
|
||||
// Get all available local IPs with their adapter names
|
||||
get_local_ips: os.output(z.array(IpInfoSchema)).handler(async () => {
|
||||
const nets = networkInterfaces();
|
||||
const results: { address: string; name: string; isRecommended: boolean }[] = [];
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
const family = net.family;
|
||||
const isIPv4 = family === 'IPv4';
|
||||
|
||||
if (!isIPv4 || net.internal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const addr = net.address;
|
||||
|
||||
// Skip non-LAN addresses
|
||||
if (addr.startsWith('169.254.')) continue; // APIPA
|
||||
if (addr.startsWith('198.18.')) continue; // CGNAT / VPN
|
||||
|
||||
// Determine if this is a recommended (likely real LAN) address
|
||||
let isRecommended = false;
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
if (addr.startsWith('192.168.') || addr.startsWith('10.')) {
|
||||
if (
|
||||
lowerName.includes('wlan') ||
|
||||
lowerName.includes('wi-fi') ||
|
||||
lowerName.includes('wireless') ||
|
||||
lowerName === '以太网' ||
|
||||
lowerName === 'ethernet' ||
|
||||
lowerName.match(/^eth\d/)
|
||||
) {
|
||||
isRecommended = true;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ address: addr, name, isRecommended });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: recommended first, then by address
|
||||
results.sort((a, b) => {
|
||||
if (a.isRecommended !== b.isRecommended) {
|
||||
return b.isRecommended ? 1 : -1;
|
||||
}
|
||||
return a.address.localeCompare(b.address);
|
||||
});
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
// Open log directory in file explorer
|
||||
openLogDirectory: os.output(z.void()).handler(async () => {
|
||||
const logDir = getAgentDir();
|
||||
await shell.openPath(logDir);
|
||||
}),
|
||||
});
|
||||
+33
-33
@@ -1,33 +1,33 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { nativeTheme } from 'electron';
|
||||
import { setThemeModeInputSchema } from './schemas';
|
||||
|
||||
export const getCurrentThemeMode = os.handler(() => {
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
|
||||
export const toggleThemeMode = os.handler(() => {
|
||||
if (nativeTheme.shouldUseDarkColors) {
|
||||
nativeTheme.themeSource = 'light';
|
||||
} else {
|
||||
nativeTheme.themeSource = 'dark';
|
||||
}
|
||||
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
});
|
||||
|
||||
export const setThemeMode = os.input(setThemeModeInputSchema).handler(({ input: mode }) => {
|
||||
switch (mode) {
|
||||
case 'light':
|
||||
nativeTheme.themeSource = 'light';
|
||||
break;
|
||||
case 'dark':
|
||||
nativeTheme.themeSource = 'dark';
|
||||
break;
|
||||
case 'system':
|
||||
nativeTheme.themeSource = 'system';
|
||||
break;
|
||||
}
|
||||
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
import { os } from '@orpc/server';
|
||||
import { nativeTheme } from 'electron';
|
||||
import { setThemeModeInputSchema } from './schemas';
|
||||
|
||||
export const getCurrentThemeMode = os.handler(() => {
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
|
||||
export const toggleThemeMode = os.handler(() => {
|
||||
if (nativeTheme.shouldUseDarkColors) {
|
||||
nativeTheme.themeSource = 'light';
|
||||
} else {
|
||||
nativeTheme.themeSource = 'dark';
|
||||
}
|
||||
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
});
|
||||
|
||||
export const setThemeMode = os.input(setThemeModeInputSchema).handler(({ input: mode }) => {
|
||||
switch (mode) {
|
||||
case 'light':
|
||||
nativeTheme.themeSource = 'light';
|
||||
break;
|
||||
case 'dark':
|
||||
nativeTheme.themeSource = 'dark';
|
||||
break;
|
||||
case 'system':
|
||||
nativeTheme.themeSource = 'system';
|
||||
break;
|
||||
}
|
||||
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getCurrentThemeMode, setThemeMode, toggleThemeMode } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const theme = os.router({
|
||||
getCurrentThemeMode,
|
||||
setThemeMode,
|
||||
toggleThemeMode,
|
||||
});
|
||||
import { getCurrentThemeMode, setThemeMode, toggleThemeMode } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const theme = os.router({
|
||||
getCurrentThemeMode,
|
||||
setThemeMode,
|
||||
toggleThemeMode,
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
|
||||
import { z } from 'zod';
|
||||
|
||||
export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
|
||||
+196
-196
@@ -1,196 +1,196 @@
|
||||
import { app, Tray, Menu, nativeImage, BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getTrayTexts } from './i18n';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let globalMainWindow: BrowserWindow | null = null;
|
||||
let lastAccount: CloudAccount | null = null;
|
||||
let lastLanguage: string = 'en';
|
||||
|
||||
function getQuotaText(account: CloudAccount | null, texts: any): string[] {
|
||||
if (!account) return [`${texts.quota}: --`];
|
||||
if (!account.quota || !account.quota.models) return [`${texts.quota}: ${texts.unknown_quota}`];
|
||||
|
||||
const lines: string[] = [];
|
||||
const models = account.quota.models;
|
||||
|
||||
let gHigh = 0;
|
||||
let gImage = 0;
|
||||
let claude = 0;
|
||||
|
||||
for (const [key, val] of Object.entries(models)) {
|
||||
const k = key.toLowerCase();
|
||||
if (k.includes('high')) gHigh = val.percentage;
|
||||
else if (k.includes('image')) gImage = val.percentage;
|
||||
else if (k.includes('claude')) claude = val.percentage;
|
||||
}
|
||||
|
||||
lines.push(`Gemini High: ${gHigh}%`);
|
||||
lines.push(`Gemini Image: ${gImage}%`);
|
||||
lines.push(`Claude 4.5: ${claude}%`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function initTray(mainWindow: BrowserWindow) {
|
||||
globalMainWindow = mainWindow;
|
||||
|
||||
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
|
||||
if (tray) {
|
||||
try {
|
||||
tray.destroy();
|
||||
} catch (e) {
|
||||
logger.error('Failed to destroy existing tray', e);
|
||||
}
|
||||
tray = null;
|
||||
logger.info('Destroyed existing tray before creating new one');
|
||||
}
|
||||
|
||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
|
||||
const iconPath = inDevelopment
|
||||
? path.join(process.cwd(), 'src/assets/tray.png')
|
||||
: path.join(process.resourcesPath, 'assets', 'tray.png');
|
||||
|
||||
logger.info(
|
||||
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
|
||||
);
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
|
||||
// Verify icon is valid before creating tray
|
||||
if (icon.isEmpty()) {
|
||||
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Gemini Nexus');
|
||||
|
||||
tray.on('double-click', () => {
|
||||
if (globalMainWindow) {
|
||||
if (globalMainWindow.isVisible()) {
|
||||
globalMainWindow.hide();
|
||||
} else {
|
||||
globalMainWindow.show();
|
||||
globalMainWindow.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateTrayMenu(null);
|
||||
}
|
||||
|
||||
export function updateTrayMenu(account: CloudAccount | null, language?: string) {
|
||||
lastAccount = account;
|
||||
if (language) {
|
||||
lastLanguage = language;
|
||||
}
|
||||
|
||||
if (!tray || !globalMainWindow) return;
|
||||
|
||||
const texts = getTrayTexts(lastLanguage);
|
||||
const quotaLines = getQuotaText(account, texts);
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: account
|
||||
? `${texts.current}: ${account.email}`
|
||||
: `${texts.current}: ${texts.no_account}`,
|
||||
enabled: false,
|
||||
},
|
||||
...quotaLines.map((line) => ({ label: line, enabled: false })),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.switch_next,
|
||||
click: async () => {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
if (accounts.length === 0) return;
|
||||
|
||||
const current = accounts.find((a) => a.is_active);
|
||||
let nextIndex = 0;
|
||||
if (current) {
|
||||
const idx = accounts.findIndex((a) => a.id === current.id);
|
||||
nextIndex = (idx + 1) % accounts.length;
|
||||
}
|
||||
const next = accounts[nextIndex];
|
||||
|
||||
CloudAccountRepo.setActive(next.id);
|
||||
logger.info(`Tray: Switched to account ${next.email}`);
|
||||
|
||||
updateTrayMenu(next, lastLanguage);
|
||||
|
||||
if (globalMainWindow) {
|
||||
globalMainWindow.webContents.send('tray://account-switched', next.id);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Tray: Switch account failed', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: texts.refresh_current,
|
||||
click: async () => {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const current = accounts.find((a) => a.is_active);
|
||||
if (!current) return;
|
||||
|
||||
logger.info(`Tray: Refreshing quota for ${current.email}`);
|
||||
|
||||
const quota = await GoogleAPIService.fetchQuota(current.token.access_token);
|
||||
await CloudAccountRepo.updateQuota(current.id, quota);
|
||||
|
||||
// Reload account to get updated obj
|
||||
const updated = await CloudAccountRepo.getAccount(current.id);
|
||||
if (updated) updateTrayMenu(updated, lastLanguage);
|
||||
|
||||
if (globalMainWindow) {
|
||||
globalMainWindow.webContents.send('tray://refresh-current');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Tray: Refresh quota failed', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.show_window,
|
||||
click: () => {
|
||||
globalMainWindow?.show();
|
||||
globalMainWindow?.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.quit,
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
tray.setContextMenu(menu);
|
||||
}
|
||||
|
||||
export function setTrayLanguage(lang: string) {
|
||||
updateTrayMenu(lastAccount, lang);
|
||||
}
|
||||
|
||||
export function destroyTray() {
|
||||
if (tray) {
|
||||
try {
|
||||
tray.destroy();
|
||||
} catch (e) {
|
||||
logger.error('Failed to destroy tray', e);
|
||||
}
|
||||
tray = null;
|
||||
logger.info('Tray destroyed');
|
||||
}
|
||||
}
|
||||
import { app, Tray, Menu, nativeImage, BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getTrayTexts } from './i18n';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let globalMainWindow: BrowserWindow | null = null;
|
||||
let lastAccount: CloudAccount | null = null;
|
||||
let lastLanguage: string = 'en';
|
||||
|
||||
function getQuotaText(account: CloudAccount | null, texts: any): string[] {
|
||||
if (!account) return [`${texts.quota}: --`];
|
||||
if (!account.quota || !account.quota.models) return [`${texts.quota}: ${texts.unknown_quota}`];
|
||||
|
||||
const lines: string[] = [];
|
||||
const models = account.quota.models;
|
||||
|
||||
let gHigh = 0;
|
||||
let gImage = 0;
|
||||
let claude = 0;
|
||||
|
||||
for (const [key, val] of Object.entries(models)) {
|
||||
const k = key.toLowerCase();
|
||||
if (k.includes('high')) gHigh = val.percentage;
|
||||
else if (k.includes('image')) gImage = val.percentage;
|
||||
else if (k.includes('claude')) claude = val.percentage;
|
||||
}
|
||||
|
||||
lines.push(`Gemini High: ${gHigh}%`);
|
||||
lines.push(`Gemini Image: ${gImage}%`);
|
||||
lines.push(`Claude 4.5: ${claude}%`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function initTray(mainWindow: BrowserWindow) {
|
||||
globalMainWindow = mainWindow;
|
||||
|
||||
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
|
||||
if (tray) {
|
||||
try {
|
||||
tray.destroy();
|
||||
} catch (e) {
|
||||
logger.error('Failed to destroy existing tray', e);
|
||||
}
|
||||
tray = null;
|
||||
logger.info('Destroyed existing tray before creating new one');
|
||||
}
|
||||
|
||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
|
||||
const iconPath = inDevelopment
|
||||
? path.join(process.cwd(), 'src/assets/tray.png')
|
||||
: path.join(process.resourcesPath, 'assets', 'tray.png');
|
||||
|
||||
logger.info(
|
||||
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
|
||||
);
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
|
||||
// Verify icon is valid before creating tray
|
||||
if (icon.isEmpty()) {
|
||||
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Gemini Nexus');
|
||||
|
||||
tray.on('double-click', () => {
|
||||
if (globalMainWindow) {
|
||||
if (globalMainWindow.isVisible()) {
|
||||
globalMainWindow.hide();
|
||||
} else {
|
||||
globalMainWindow.show();
|
||||
globalMainWindow.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateTrayMenu(null);
|
||||
}
|
||||
|
||||
export function updateTrayMenu(account: CloudAccount | null, language?: string) {
|
||||
lastAccount = account;
|
||||
if (language) {
|
||||
lastLanguage = language;
|
||||
}
|
||||
|
||||
if (!tray || !globalMainWindow) return;
|
||||
|
||||
const texts = getTrayTexts(lastLanguage);
|
||||
const quotaLines = getQuotaText(account, texts);
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: account
|
||||
? `${texts.current}: ${account.email}`
|
||||
: `${texts.current}: ${texts.no_account}`,
|
||||
enabled: false,
|
||||
},
|
||||
...quotaLines.map((line) => ({ label: line, enabled: false })),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.switch_next,
|
||||
click: async () => {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
if (accounts.length === 0) return;
|
||||
|
||||
const current = accounts.find((a) => a.is_active);
|
||||
let nextIndex = 0;
|
||||
if (current) {
|
||||
const idx = accounts.findIndex((a) => a.id === current.id);
|
||||
nextIndex = (idx + 1) % accounts.length;
|
||||
}
|
||||
const next = accounts[nextIndex];
|
||||
|
||||
CloudAccountRepo.setActive(next.id);
|
||||
logger.info(`Tray: Switched to account ${next.email}`);
|
||||
|
||||
updateTrayMenu(next, lastLanguage);
|
||||
|
||||
if (globalMainWindow) {
|
||||
globalMainWindow.webContents.send('tray://account-switched', next.id);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Tray: Switch account failed', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: texts.refresh_current,
|
||||
click: async () => {
|
||||
try {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const current = accounts.find((a) => a.is_active);
|
||||
if (!current) return;
|
||||
|
||||
logger.info(`Tray: Refreshing quota for ${current.email}`);
|
||||
|
||||
const quota = await GoogleAPIService.fetchQuota(current.token.access_token);
|
||||
await CloudAccountRepo.updateQuota(current.id, quota);
|
||||
|
||||
// Reload account to get updated obj
|
||||
const updated = await CloudAccountRepo.getAccount(current.id);
|
||||
if (updated) updateTrayMenu(updated, lastLanguage);
|
||||
|
||||
if (globalMainWindow) {
|
||||
globalMainWindow.webContents.send('tray://refresh-current');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Tray: Refresh quota failed', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.show_window,
|
||||
click: () => {
|
||||
globalMainWindow?.show();
|
||||
globalMainWindow?.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: texts.quit,
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
tray.setContextMenu(menu);
|
||||
}
|
||||
|
||||
export function setTrayLanguage(lang: string) {
|
||||
updateTrayMenu(lastAccount, lang);
|
||||
}
|
||||
|
||||
export function destroyTray() {
|
||||
if (tray) {
|
||||
try {
|
||||
tray.destroy();
|
||||
} catch (e) {
|
||||
logger.error('Failed to destroy tray', e);
|
||||
}
|
||||
tray = null;
|
||||
logger.info('Tray destroyed');
|
||||
}
|
||||
}
|
||||
+72
-72
@@ -1,72 +1,72 @@
|
||||
export type TrayTexts = {
|
||||
current: string;
|
||||
quota: string;
|
||||
switch_next: string;
|
||||
refresh_current: string;
|
||||
show_window: string;
|
||||
quit: string;
|
||||
no_account: string;
|
||||
unknown_quota: string;
|
||||
forbidden: string;
|
||||
};
|
||||
|
||||
const en: TrayTexts = {
|
||||
current: 'Current',
|
||||
quota: 'Quota',
|
||||
switch_next: 'Switch to Next Account',
|
||||
refresh_current: 'Refresh Current Quota',
|
||||
show_window: 'Show Main Window',
|
||||
quit: 'Quit Application',
|
||||
no_account: 'No Account',
|
||||
unknown_quota: 'Unknown',
|
||||
forbidden: 'Account Forbidden',
|
||||
};
|
||||
|
||||
const zh: TrayTexts = {
|
||||
current: '当前账号',
|
||||
quota: '当前额度',
|
||||
switch_next: '切换到下一个账号',
|
||||
refresh_current: '刷新当前额度',
|
||||
show_window: '显示主窗口',
|
||||
quit: '退出应用',
|
||||
no_account: '无账号',
|
||||
unknown_quota: '未知',
|
||||
forbidden: '账号已被禁用',
|
||||
};
|
||||
|
||||
const ru: TrayTexts = {
|
||||
current: 'Текущий',
|
||||
quota: 'Квота',
|
||||
switch_next: 'Переключить на следующий аккаунт',
|
||||
refresh_current: 'Обновить текущую квоту',
|
||||
show_window: 'Показать главное окно',
|
||||
quit: 'Выйти из приложения',
|
||||
no_account: 'Нет аккаунта',
|
||||
unknown_quota: 'Неизвестно',
|
||||
forbidden: 'Аккаунт заблокирован',
|
||||
};
|
||||
|
||||
const vi: TrayTexts = {
|
||||
current: 'Hiện tại',
|
||||
quota: 'Quota',
|
||||
switch_next: 'Chuyển sang tài khoản tiếp theo',
|
||||
refresh_current: 'Làm mới quota hiện tại',
|
||||
show_window: 'Hiện cửa sổ chính',
|
||||
quit: 'Thoát ứng dụng',
|
||||
no_account: 'Không có tài khoản',
|
||||
unknown_quota: 'Không rõ',
|
||||
forbidden: 'Tài khoản bị cấm',
|
||||
};
|
||||
|
||||
export function getTrayTexts(lang: string = 'en'): TrayTexts {
|
||||
if (lang.startsWith('zh')) {
|
||||
return zh;
|
||||
}
|
||||
if (lang.startsWith('ru')) {
|
||||
return ru;
|
||||
}
|
||||
if (lang.startsWith('vi')) {
|
||||
return vi;
|
||||
}
|
||||
return en;
|
||||
}
|
||||
export type TrayTexts = {
|
||||
current: string;
|
||||
quota: string;
|
||||
switch_next: string;
|
||||
refresh_current: string;
|
||||
show_window: string;
|
||||
quit: string;
|
||||
no_account: string;
|
||||
unknown_quota: string;
|
||||
forbidden: string;
|
||||
};
|
||||
|
||||
const en: TrayTexts = {
|
||||
current: 'Current',
|
||||
quota: 'Quota',
|
||||
switch_next: 'Switch to Next Account',
|
||||
refresh_current: 'Refresh Current Quota',
|
||||
show_window: 'Show Main Window',
|
||||
quit: 'Quit Application',
|
||||
no_account: 'No Account',
|
||||
unknown_quota: 'Unknown',
|
||||
forbidden: 'Account Forbidden',
|
||||
};
|
||||
|
||||
const zh: TrayTexts = {
|
||||
current: '当前账号',
|
||||
quota: '当前额度',
|
||||
switch_next: '切换到下一个账号',
|
||||
refresh_current: '刷新当前额度',
|
||||
show_window: '显示主窗口',
|
||||
quit: '退出应用',
|
||||
no_account: '无账号',
|
||||
unknown_quota: '未知',
|
||||
forbidden: '账号已被禁用',
|
||||
};
|
||||
|
||||
const ru: TrayTexts = {
|
||||
current: 'Текущий',
|
||||
quota: 'Квота',
|
||||
switch_next: 'Переключить на следующий аккаунт',
|
||||
refresh_current: 'Обновить текущую квоту',
|
||||
show_window: 'Показать главное окно',
|
||||
quit: 'Выйти из приложения',
|
||||
no_account: 'Нет аккаунта',
|
||||
unknown_quota: 'Неизвестно',
|
||||
forbidden: 'Аккаунт заблокирован',
|
||||
};
|
||||
|
||||
const vi: TrayTexts = {
|
||||
current: 'Hiện tại',
|
||||
quota: 'Quota',
|
||||
switch_next: 'Chuyển sang tài khoản tiếp theo',
|
||||
refresh_current: 'Làm mới quota hiện tại',
|
||||
show_window: 'Hiện cửa sổ chính',
|
||||
quit: 'Thoát ứng dụng',
|
||||
no_account: 'Không có tài khoản',
|
||||
unknown_quota: 'Không rõ',
|
||||
forbidden: 'Tài khoản bị cấm',
|
||||
};
|
||||
|
||||
export function getTrayTexts(lang: string = 'en'): TrayTexts {
|
||||
if (lang.startsWith('zh')) {
|
||||
return zh;
|
||||
}
|
||||
if (lang.startsWith('ru')) {
|
||||
return ru;
|
||||
}
|
||||
if (lang.startsWith('vi')) {
|
||||
return vi;
|
||||
}
|
||||
return en;
|
||||
}
|
||||
+70
-70
@@ -1,70 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { TokenUsageRepo } from '../database/usageHandler';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
const RecordUsageInputSchema = z.object({
|
||||
accountId: z.string(),
|
||||
model: z.string(),
|
||||
promptTokens: z.number().int().min(0),
|
||||
completionTokens: z.number().int().min(0),
|
||||
totalTokens: z.number().int().min(0),
|
||||
timestamp: z.number().int(),
|
||||
requestType: z.string().optional(),
|
||||
});
|
||||
|
||||
const UsageQueryInputSchema = z.object({
|
||||
accountId: z.string().optional(),
|
||||
start: z.number().int().optional(),
|
||||
end: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const usageRouter = os.router({
|
||||
recordUsage: os
|
||||
.input(RecordUsageInputSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
TokenUsageRepo.recordUsage(input);
|
||||
}),
|
||||
|
||||
getUsageByHour: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(HourlyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByHour(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByDay: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(DailyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByDay(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByWeek: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(WeeklyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByWeek(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByMonth: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(MonthlyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByMonth(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByModel: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(ModelUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByModel(input.accountId, input.start, input.end);
|
||||
}),
|
||||
});
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { TokenUsageRepo } from '../database/usageHandler';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
const RecordUsageInputSchema = z.object({
|
||||
accountId: z.string(),
|
||||
model: z.string(),
|
||||
promptTokens: z.number().int().min(0),
|
||||
completionTokens: z.number().int().min(0),
|
||||
totalTokens: z.number().int().min(0),
|
||||
timestamp: z.number().int(),
|
||||
requestType: z.string().optional(),
|
||||
});
|
||||
|
||||
const UsageQueryInputSchema = z.object({
|
||||
accountId: z.string().optional(),
|
||||
start: z.number().int().optional(),
|
||||
end: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const usageRouter = os.router({
|
||||
recordUsage: os
|
||||
.input(RecordUsageInputSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
TokenUsageRepo.recordUsage(input);
|
||||
}),
|
||||
|
||||
getUsageByHour: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(HourlyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByHour(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByDay: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(DailyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByDay(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByWeek: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(WeeklyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByWeek(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByMonth: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(MonthlyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByMonth(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByModel: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(ModelUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByModel(input.accountId, input.start, input.end);
|
||||
}),
|
||||
});
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { ipcContext } from '../context';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export const minimizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
const { window } = context;
|
||||
|
||||
window.minimize();
|
||||
});
|
||||
|
||||
export const maximizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
const { window } = context;
|
||||
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
export const closeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
logger.info('IPC: closeWindow called');
|
||||
const { window } = context;
|
||||
|
||||
window.close();
|
||||
});
|
||||
import { os } from '@orpc/server';
|
||||
import { ipcContext } from '../context';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export const minimizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
const { window } = context;
|
||||
|
||||
window.minimize();
|
||||
});
|
||||
|
||||
export const maximizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
const { window } = context;
|
||||
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
export const closeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
logger.info('IPC: closeWindow called');
|
||||
const { window } = context;
|
||||
|
||||
window.close();
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { closeWindow, maximizeWindow, minimizeWindow } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const window = os.router({
|
||||
minimizeWindow,
|
||||
maximizeWindow,
|
||||
closeWindow,
|
||||
});
|
||||
import { closeWindow, maximizeWindow, minimizeWindow } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const window = os.router({
|
||||
minimizeWindow,
|
||||
maximizeWindow,
|
||||
closeWindow,
|
||||
});
|
||||
Reference in new issue
Block a user