Refactor UUID generation, remove unused logger and encryption utilities, and clean up request handling

- Updated `generateUUID` function for improved readability and performance.
- Deleted `logger.ts`, `other.ts`, `request.ts`, `storage.ts`, `tansParams.ts`, and `validate.ts` as they were no longer needed.
- Simplified TypeScript configuration by removing unnecessary paths and aliases.
- Enhanced Vite configuration for better project structure and maintainability.
This commit is contained in:
DEV_DSW
2026-04-17 15:38:08 +08:00
parent b1dea9a5c2
commit 79bea4f107
360 changed files with 14495 additions and 30856 deletions

View File

@@ -1,143 +1,31 @@
// 渲染进程|主进程常量定义
import { CONFIG_KEYS as RUNTIME_CONFIG_KEYS } from '../types/runtime';
export enum IPC_EVENTS {
EXTERNAL_OPEN = 'external-open',
APP_SET_FRAMELESS = 'app:set-frameless',
APP_LOAD_PAGE = 'app:load-page',
TAB_CREATE = 'tab:create',
TAB_LIST = 'tab:list',
TAB_NAVIGATE = 'tab:navigate',
TAB_RELOAD = 'tab:reload',
TAB_BACK = 'tab:back',
TAB_FORWARD = 'tab:forward',
TAB_SWITCH = 'tab:switch',
TAB_CLOSE = 'tab:close',
LOG_TO_MAIN = 'log-to-main',
READ_FILE = 'read-file',
INVOKE = 'ipc:invoke',
INVOKE_ASYNC = 'ipc:invokeAsync',
APP_MINIMIZE ='app:minimize',
APP_MAXIMIZE ='app:maximize',
APP_QUIT ='app:quit',
FILE_READ = 'file:read',
FILE_WRITE = 'file:write',
GET_WINDOW_ID='get-window-id',
CUSTOM_EVENT ='custom:event',
TIME_UPDATE = 'time:update',
RENDERER_IS_READY = 'renderer-ready',
SHOW_CONTEXT_MENU = 'show-context-menu',
START_A_DIALOGUE = 'start-a-dialogue',
export const IPC_EVENTS = {
HOST_API_FETCH: 'hostapi:fetch',
GATEWAY_RPC: 'gateway:rpc',
GATEWAY_EVENT: 'gateway:event',
GET_CONFIG: 'get-config',
SET_CONFIG: 'set-config',
GET_THEME_MODE: 'get-theme-mode',
SET_THEME_MODE: 'set-theme-mode',
THEME_MODE_UPDATED: 'theme-mode-updated',
GET_WINDOW_ID: 'get-window-id',
TASK_PROGRESS: 'task:progress',
TASK_STARTED: 'task:started',
TASK_COMPLETED: 'task:completed',
OPEN_CHANNEL: 'open-channel',
EXECUTE_SCRIPT: 'execute-script',
} as const;
// 打开窗口
OPEN_WINDOW = 'open-window',
export const CONFIG_KEYS = RUNTIME_CONFIG_KEYS;
// 发送日志
LOG_DEBUG = 'log-debug',
LOG_INFO = 'log-info',
LOG_WARN = 'log-warn',
LOG_ERROR = 'log-error',
export const WINDOW_NAMES = {
MAIN: 'main',
SETTING: 'setting',
DIALOG: 'dialog',
LOADING: 'loading',
} as const;
// 设置
CONFIG_UPDATED = 'config-updated',
SET_CONFIG = 'set-config',
GET_CONFIG = 'get-config',
UPDATE_CONFIG = 'update-config',
export const DEFAULT_THEME_MODE = 'system' as const;
// 主题
SET_THEME_MODE = 'set-theme-mode',
GET_THEME_MODE = 'get-theme-mode',
IS_DARK_THEME = 'is-dark-theme',
THEME_MODE_UPDATED = 'theme-mode-updated',
// 执行脚本
EXECUTE_SCRIPT = 'execute-script',
// 任务事件
TASK_PROGRESS = 'task:progress',
TASK_STARTED = 'task:started',
TASK_COMPLETED = 'task:completed',
// 打开渠道
OPEN_CHANNEL = 'open-channel',
// 脚本管理
SCRIPT_LIST = 'script:list',
SCRIPT_GET = 'script:get',
SCRIPT_SAVE = 'script:save',
SCRIPT_DELETE = 'script:delete',
SCRIPT_TOGGLE = 'script:toggle',
SCRIPT_RUN = 'script:run',
SCRIPT_RECORD_START = 'script:record-start',
SCRIPT_RECORD_STOP = 'script:record-stop',
SCRIPT_CODEGEN = 'script:codegen',
// Gateway (对齐 ClawX)
GATEWAY_RPC = 'gateway:rpc',
GATEWAY_EVENT = 'gateway:event',
// 更新
UPDATE_CHECK = 'update:check',
UPDATE_DOWNLOAD = 'update:download',
UPDATE_INSTALL = 'update:install',
UPDATE_VERSION = 'update:version',
UPDATE_STATUS_CHANGED = 'update:status-changed',
}
export const MAIN_WIN_SIZE = {
width: 1440,
height: 900,
minWidth: 1440,
minHeight: 900,
} as const
export enum WINDOW_NAMES {
MAIN = 'main',
SETTING = 'setting',
DIALOG = 'dialog',
LOADING = 'loading',
}
export enum CONFIG_KEYS {
THEME_MODE = 'themeMode',
PRIMARY_COLOR = 'primaryColor',
LANGUAGE = 'language',
FONT_SIZE = 'fontSize',
MINIMIZE_TO_TRAY = 'minimizeToTray',
PROVIDER = 'provider',
DEFAULT_MODEL = 'defaultModel',
AUTO_CHECK_UPDATE = 'autoCheckUpdate',
AUTO_DOWNLOAD_UPDATE = 'autoDownloadUpdate',
SELECTED_CHANNELS = 'selectedChannels',
IMAGE_CACHE = 'imageCache',
TASK_LIST = 'taskList',
}
export enum MENU_IDS {
CONVERSATION_ITEM = 'conversation-item',
CONVERSATION_LIST = 'conversation-list',
MESSAGE_ITEM = 'message-item',
}
export enum CONVERSATION_ITEM_MENU_IDS {
PIN = 'pin',
RENAME = 'rename',
DEL = 'del',
}
export enum CONVERSATION_LIST_MENU_IDS {
NEW_CONVERSATION = 'newConversation',
SORT_BY = 'sortBy',
SORT_BY_CREATE_TIME = 'sortByCreateTime',
SORT_BY_UPDATE_TIME = 'sortByUpdateTime',
SORT_BY_NAME = 'sortByName',
SORT_BY_MODEL = 'sortByModel',
SORT_ASCENDING = 'sortAscending',
SORT_DESCENDING = 'sortDescending',
BATCH_OPERATIONS = 'batchOperations',
}
export enum MESSAGE_ITEM_MENU_IDS {
COPY = 'copy',
DELETE = 'delete',
SELECT = 'select',
}
export const DEFAULT_LANGUAGE = 'zh' as const;

View File

@@ -1,8 +1,3 @@
/**
* Cron Job Type Definitions
* Types for scheduled tasks
*/
export type CronJobDeliveryMode = 'none' | 'announce';
export interface CronJobDelivery {
@@ -12,9 +7,6 @@ export interface CronJobDelivery {
accountId?: string;
}
/**
* Cron job last run info
*/
export interface CronJobLastRun {
time: string;
success: boolean;
@@ -22,18 +14,11 @@ export interface CronJobLastRun {
duration?: number;
}
/**
* Gateway CronSchedule object format
*/
export type CronSchedule =
| { kind: 'at'; at: string }
| { kind: 'every'; everyMs: number; anchorMs?: number }
| { kind: 'cron'; expr: string; tz?: string };
/**
* Cron job data structure
* schedule can be a plain cron string or a Gateway CronSchedule object
*/
export interface CronJob {
id: string;
name: string;
@@ -47,9 +32,6 @@ export interface CronJob {
nextRun?: string;
}
/**
* Input for creating a cron job from the UI.
*/
export interface CronJobCreateInput {
name: string;
message: string;
@@ -58,9 +40,6 @@ export interface CronJobCreateInput {
enabled?: boolean;
}
/**
* Input for updating a cron job
*/
export interface CronJobUpdateInput {
name?: string;
message?: string;
@@ -69,7 +48,4 @@ export interface CronJobUpdateInput {
enabled?: boolean;
}
/**
* Schedule type for UI picker
*/
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'interval' | 'custom';

View File

@@ -1,15 +1,12 @@
import { IPC_EVENTS } from '@lib/constants';
import type { GatewayEvent } from '@electron/gateway/types';
import { IPC_EVENTS } from './constants';
import { invokeIpc, onIpc } from './host-api';
import type { GatewayEvent } from '../types/runtime';
export async function gatewayRpc<T = any>(method: string, params?: any): Promise<T> {
if (!window.api?.invoke) {
throw new Error('IPC not available');
}
return window.api.invoke(IPC_EVENTS.GATEWAY_RPC, method, params);
export async function gatewayRpc<T = unknown>(method: string, params?: unknown): Promise<T> {
return invokeIpc<T>(IPC_EVENTS.GATEWAY_RPC, method, params);
}
export function onGatewayEvent(
callback: (event: GatewayEvent) => void
): () => void {
return window.api.on(IPC_EVENTS.GATEWAY_EVENT, callback as (event: any) => void);
export function onGatewayEvent(callback: (event: GatewayEvent) => void): () => void {
return onIpc(IPC_EVENTS.GATEWAY_EVENT, callback as (...args: any[]) => void);
}

View File

@@ -1,52 +1,146 @@
import { IPC_EVENTS } from '@lib/constants';
import { Session } from '@utils/storage';
import { IPC_EVENTS } from './constants';
import type { HostApiResult } from '../types/runtime';
import { logout, readPersistedAuthToken } from '../router/auth-session';
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const method = init?.method || 'GET';
const token = Session.get('token');
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
type RequestInitLike = Pick<RequestInit, 'method' | 'headers' | 'body'>;
try {
// Attempt to call via IPC if window.api exists
if ((window as any).api && (window as any).api.invoke) {
const response = await (window as any).api.invoke('hostapi:fetch', {
path,
method,
headers: {
...authHeaders,
...(init?.headers || {}),
},
body: init?.body ?? null,
});
if (response && typeof response === 'object') {
if (response.success || response.ok) {
return (response.json ?? response.data?.json ?? response.data ?? []) as T;
} else {
throw new Error(response.error || response.text || 'Request failed');
}
}
return response as T;
}
} catch (error) {
console.warn(`[hostApiFetch] IPC request failed for ${path}:`, error);
}
type LooseIpcBridge = {
invoke<T = unknown>(channel: string, ...args: any[]): Promise<T>;
on?(channel: string, callback: (...args: any[]) => void): () => void;
};
// Dummy fallback for UI development if backend is not ready
console.warn(`[hostApiFetch] Using dummy data for ${path}`);
if (path.includes('recent-token-history')) {
return [] as any as T;
}
if (path.includes('provider-accounts')) {
return (method === 'GET' ? [] : { success: true }) as any as T;
}
if (path.includes('providers')) {
return (method === 'GET' ? [] : { success: true }) as any as T;
}
if (path.includes('provider-vendors')) {
return [] as any as T;
}
return {} as T;
function normalizeHeaders(headers?: HeadersInit): Headers {
return new Headers(headers ?? {});
}
function normalizeBody(body: BodyInit | null | undefined): BodyInit | null | undefined {
if (body == null) return body;
if (
typeof body === 'string' ||
body instanceof Blob ||
body instanceof FormData ||
body instanceof URLSearchParams ||
body instanceof ArrayBuffer
) {
return body;
}
if (ArrayBuffer.isView(body)) {
return body;
}
return JSON.stringify(body);
}
function extractResult<T>(response: unknown): T {
if (response && typeof response === 'object') {
const result = response as HostApiResult<T>;
if (result.success === false || result.ok === false) {
if (isUnauthorizedStatus(result.status) || isUnauthorizedMessage(result.error) || isUnauthorizedMessage(result.text)) {
handleUnauthorized();
}
throw new Error(result.error || result.text || 'Request failed');
}
if (typeof result.json !== 'undefined') return result.json;
if (typeof result.data !== 'undefined') {
const data = result.data as { json?: T } | T;
return (data && typeof data === 'object' && 'json' in data ? data.json : data) as T;
}
}
return response as T;
}
function isUnauthorizedStatus(status?: number): boolean {
return status === 401;
}
function isUnauthorizedMessage(message?: string): boolean {
if (!message) return false;
return /\b401\b|unauthorized|unauthenticated|invalid token|token expired|鉴权失败|认证失败|未授权|未登录|登录失效|token已失效/i.test(message);
}
function handleUnauthorized(): void {
const from = typeof window === 'undefined' ? undefined : window.location.hash.replace(/^#/, '') || undefined;
logout({ reason: 'unauthorized', from });
}
export function hasHostApiBridge(): boolean {
return typeof window !== 'undefined' && Boolean(window.api?.invoke);
}
export async function invokeIpc<T = unknown>(channel: string, ...args: any[]): Promise<T> {
if (!hasHostApiBridge()) {
throw new Error(`IPC bridge is unavailable for ${channel}`);
}
const bridge = window.api as unknown as LooseIpcBridge;
return bridge.invoke<T>(channel, ...args);
}
export function onIpc(channel: string, callback: (...args: any[]) => void): () => void {
if (!hasHostApiBridge() || !window.api?.on) {
return () => {};
}
const bridge = window.api as unknown as LooseIpcBridge;
return bridge.on ? bridge.on(channel, callback) : () => {};
}
export async function hostApiFetch<T>(path: string, init?: RequestInitLike): Promise<T> {
const method = init?.method ?? 'GET';
const headers = normalizeHeaders(init?.headers);
const token = readPersistedAuthToken();
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
const request = {
path,
method,
headers: (() => {
const headerObject: Record<string, string> = {};
headers.forEach((value, key) => {
headerObject[key] = value;
});
return headerObject;
})(),
body: init?.body ?? null,
};
if (hasHostApiBridge()) {
const response = await invokeIpc(IPC_EVENTS.HOST_API_FETCH, request);
return extractResult<T>(response);
}
if (typeof fetch === 'function') {
const response = await fetch(path, {
method,
headers,
body: normalizeBody(init?.body),
});
if (!response.ok) {
if (isUnauthorizedStatus(response.status)) {
handleUnauthorized();
}
const text = await response.text();
if (!isUnauthorizedStatus(response.status) && isUnauthorizedMessage(text)) {
handleUnauthorized();
}
throw new Error(text || response.statusText || `Request failed with ${response.status}`);
}
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
return (await response.json()) as T;
}
return (await response.text()) as unknown as T;
}
throw new Error(`No HTTP bridge available for ${path}`);
}

View File

@@ -1,331 +1,52 @@
/**
* 工具函数集合
* 包含打字机效果、ID生成、回调安全调用等通用工具函数
*/
export {
CONFIG_KEYS,
DEFAULT_LANGUAGE,
DEFAULT_THEME_MODE,
IPC_EVENTS,
WINDOW_NAMES,
} from './constants';
/* =======================
* ID 生成工具
* ======================= */
export class IdUtils {
/**
* 生成消息 ID
*/
static generateMessageId(): string {
const timestamp = Date.now()
const chars = 'abcdefghijklmnopqrstuvwxyz'
const randomStr = Array.from({ length: 4 }, () =>
chars.charAt(Math.floor(Math.random() * chars.length))
).join('')
return `mid${randomStr}${timestamp}`
}
}
export {
hostApiFetch,
hostApiFetch as fetchFromHost,
hasHostApiBridge,
invokeIpc,
onIpc,
} from './host-api';
/* =======================
* 回调安全调用工具
* ======================= */
export {
gatewayRpc,
gatewayRpc as callGateway,
onGatewayEvent,
} from './gateway-client';
export type CallbackMap = Record<string, (...args: any[]) => any>
export {
applyThemeModeToDocument,
detectSystemTheme,
resolveAppliedTheme,
watchSystemTheme,
} from './theme';
export interface BatchCallbackConfig {
name: string
args?: any[]
}
export {
detectRuntimePlatform,
detectWindowName,
hasIpcBridge,
resolveWindowIdentity,
} from './runtime';
export class CallbackUtils {
/**
* 安全调用回调函数
*/
static safeCall(
callbacks: CallbackMap | null | undefined,
callbackName: string,
...args: any[]
): void {
const cb = callbacks?.[callbackName]
if (typeof cb === 'function') {
try {
cb(...args)
} catch (error) {
console.error(`回调函数 ${callbackName} 执行出错:`, error)
}
} else {
console.warn(`回调函数 ${callbackName} 不可用`)
}
}
/**
* 批量安全调用回调函数
*/
static safeBatchCall(
callbacks: CallbackMap | null | undefined,
callbackConfigs: BatchCallbackConfig[]
): void {
callbackConfigs.forEach(({ name, args = [] }) => {
this.safeCall(callbacks, name, ...args)
})
}
}
/* =======================
* 消息处理工具
* ======================= */
export interface BaseMessage {
type: string
content?: any
timestamp: number
isComplete?: boolean
[key: string]: any
}
export class MessageUtils {
/**
* 验证消息格式
*/
static validateMessage(message: unknown): message is BaseMessage {
return (
typeof message === 'object' &&
message !== null &&
'type' in message
)
}
/**
* 格式化消息
*/
static formatMessage<T = any>(
type: string,
content: T,
options: Partial<BaseMessage> = {}
): BaseMessage {
return {
type,
content,
timestamp: Date.now(),
...options,
}
}
/**
* 是否为完整消息
*/
static isCompleteMessage(message: Partial<BaseMessage> | null | undefined): boolean {
return message?.isComplete === true
}
/**
* 是否为心跳 pong
*/
static isPongMessage(messageData: unknown): boolean {
if (typeof messageData === 'string') {
return messageData === 'pong' || messageData.toLowerCase().includes('pong')
}
if (typeof messageData === 'object' && messageData !== null) {
return (messageData as any).type === 'pong'
}
return false
}
/**
* 安全解析 JSON
*/
static safeParseJSON<T = any>(messageStr: string): T | null {
try {
return JSON.parse(messageStr) as T
} catch {
console.warn('JSON 解析失败:', messageStr)
return null
}
}
/**
* 创建打字机消息
*/
static createTypewriterMessage(
content: string,
isComplete = false,
type = 'typewriter'
): BaseMessage {
return {
type,
content,
isComplete,
timestamp: Date.now(),
}
}
/**
* 创建加载消息
*/
static createLoadingMessage(content = '加载中...'): BaseMessage {
return {
type: 'loading',
content,
timestamp: Date.now(),
}
}
/**
* 创建错误消息
*/
static createErrorMessage(error: unknown): BaseMessage {
return {
type: 'error',
content:
error instanceof Error ? error.message : String(error ?? '未知错误'),
timestamp: Date.now(),
}
}
}
/* =======================
* 定时器工具
* ======================= */
export type TimerType = 'timeout' | 'interval'
export interface CancelableTimer {
cancel(): void
isActive(): boolean
}
export class TimerUtils {
static safeClear(
timerId: number | null,
type: TimerType = 'timeout'
): null {
if (timerId !== null) {
type === 'interval' ? clearInterval(timerId) : clearTimeout(timerId)
}
return null
}
static clearTimer(
timerId: number | null,
type: TimerType = 'timeout'
): null {
return this.safeClear(timerId, type)
}
static createCancelableTimeout(
callback: () => void,
delay: number
): CancelableTimer {
let timerId: number | null = window.setTimeout(callback, delay)
return {
cancel() {
if (timerId !== null) {
clearTimeout(timerId)
timerId = null
}
},
isActive() {
return timerId !== null
},
}
}
static createCancelableInterval(
callback: () => void,
interval: number
): CancelableTimer {
let timerId: number | null = window.setInterval(callback, interval)
return {
cancel() {
if (timerId !== null) {
clearInterval(timerId)
timerId = null
}
},
isActive() {
return timerId !== null
},
}
}
}
/* =======================
* 防抖工具
* ======================= */
export class DebounceUtils {
static createDebounce<T extends (...args: any[]) => void>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timerId: number | null = null
return function (this: unknown, ...args: Parameters<T>) {
if (timerId !== null) {
clearTimeout(timerId)
}
timerId = window.setTimeout(() => func.apply(this, args), delay)
}
}
}
/* =======================
* 节流工具
* ======================= */
export class ThrottleUtils {
static createThrottle<T extends (...args: any[]) => void>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let prev = Date.now()
return function (this: unknown, ...args: Parameters<T>) {
const now = Date.now()
if (now - prev >= delay) {
func.apply(this, args)
prev = now
}
}
}
}
/* =======================
* 日期工具
* ======================= */
export class DateUtils {
static formatDate(
date: Date = new Date(),
format: string = 'yyyy-MM-dd'
): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return format
.replace('yyyy', String(year))
.replace('MM', month)
.replace('dd', day)
}
}
/* =======================
* 手机号校验
* ======================= */
export class PhoneUtils {
static validatePhone(phone: string): boolean {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
}
/* =======================
* 默认导出
* ======================= */
export default {
IdUtils,
CallbackUtils,
MessageUtils,
TimerUtils,
DateUtils,
DebounceUtils,
ThrottleUtils,
PhoneUtils,
}
export type {
ConfigKey,
ConfigValueKey,
ConfigValueMap,
GatewayEvent,
HostApiResult,
IpcArgs,
IpcListener,
LanguageCode,
ResolvedThemeMode,
RuntimePlatform,
ThemeMode,
WindowApiBridge,
WindowIdentity,
WindowName,
} from '../types/runtime';

View File

@@ -1,126 +0,0 @@
import { hostApiFetch } from '@lib/host-api';
import type {
ProviderAccount,
ProviderType,
ProviderVendorInfo,
ProviderWithKeyInfo,
} from '@lib/providers';
export interface ProviderSnapshot {
accounts: ProviderAccount[];
statuses: ProviderWithKeyInfo[];
vendors: ProviderVendorInfo[];
defaultAccountId: string | null;
}
export interface ProviderListItem {
account: ProviderAccount;
vendor?: ProviderVendorInfo;
status?: ProviderWithKeyInfo;
}
export async function fetchProviderSnapshot(): Promise<ProviderSnapshot> {
const [accounts, statuses, vendors, defaultInfo] = await Promise.all([
hostApiFetch<ProviderAccount[]>('/api/provider-accounts'),
hostApiFetch<ProviderWithKeyInfo[]>('/api/providers'),
hostApiFetch<ProviderVendorInfo[]>('/api/provider-vendors'),
hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default'),
]);
return {
accounts,
statuses,
vendors,
defaultAccountId: defaultInfo.accountId,
};
}
export function hasConfiguredCredentials(
account: ProviderAccount,
status?: ProviderWithKeyInfo,
): boolean {
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') {
return true;
}
return status?.hasKey ?? false;
}
export function pickPreferredAccount(
accounts: ProviderAccount[],
defaultAccountId: string | null,
vendorId: ProviderType | string,
statusMap: Map<string, ProviderWithKeyInfo>,
): ProviderAccount | null {
const sameVendor = accounts.filter((account) => account.vendorId === vendorId);
if (sameVendor.length === 0) return null;
return (
(defaultAccountId ? sameVendor.find((account) => account.id === defaultAccountId) : undefined)
|| sameVendor.find((account) => hasConfiguredCredentials(account, statusMap.get(account.id)))
|| sameVendor[0]
);
}
export function buildProviderAccountId(
vendorId: ProviderType,
existingAccountId: string | null,
vendors: ProviderVendorInfo[],
): string {
if (existingAccountId) {
return existingAccountId;
}
const vendor = vendors.find((candidate) => candidate.id === vendorId);
return vendor?.supportsMultipleAccounts ? `${vendorId}-${crypto.randomUUID()}` : vendorId;
}
export function legacyProviderToAccount(provider: ProviderWithKeyInfo): ProviderAccount {
return {
id: provider.id,
vendorId: provider.type,
label: provider.name,
authMode: provider.type === 'ollama' ? 'local' : 'api_key',
baseUrl: provider.baseUrl,
headers: provider.headers,
model: provider.model,
fallbackModels: provider.fallbackModels,
fallbackAccountIds: provider.fallbackProviderIds,
enabled: provider.enabled,
isDefault: false,
createdAt: provider.createdAt,
updatedAt: provider.updatedAt,
};
}
export function buildProviderListItems(
accounts: ProviderAccount[],
statuses: ProviderWithKeyInfo[],
vendors: ProviderVendorInfo[],
defaultAccountId: string | null,
): ProviderListItem[] {
const safeAccounts = accounts ?? [];
const safeStatuses = statuses ?? [];
const safeVendors = vendors ?? [];
const vendorMap = new Map(safeVendors.map((vendor) => [vendor.id, vendor]));
const statusMap = new Map(safeStatuses.map((status) => [status.id, status]));
if (safeAccounts.length > 0) {
return safeAccounts
.map((account) => ({
account,
vendor: vendorMap.get(account.vendorId),
status: statusMap.get(account.id),
}))
.sort((left, right) => {
if (left.account.id === defaultAccountId) return -1;
if (right.account.id === defaultAccountId) return 1;
return right.account.updatedAt.localeCompare(left.account.updatedAt);
});
}
return safeStatuses.map((status) => ({
account: legacyProviderToAccount(status),
vendor: vendorMap.get(status.type),
status,
}));
}

View File

@@ -182,7 +182,7 @@ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
},
];
export function getProviderIconUrl(type: ProviderType | string): string | undefined {
export function getProviderIconUrl(_type: ProviderType | string): string | undefined {
return undefined;
}
@@ -193,12 +193,12 @@ export function shouldInvertInDark(_type: ProviderType | string): boolean {
export const SETUP_PROVIDERS = PROVIDER_TYPE_INFO;
export function getProviderTypeInfo(type: ProviderType): ProviderTypeInfo | undefined {
return PROVIDER_TYPE_INFO.find((t) => t.id === type);
return PROVIDER_TYPE_INFO.find((providerType) => providerType.id === type);
}
export function getProviderDocsUrl(
provider: Pick<ProviderTypeInfo, 'docsUrl' | 'docsUrlZh'> | undefined,
language: string
language: string,
): string | undefined {
if (!provider?.docsUrl) {
return undefined;
@@ -211,7 +211,7 @@ export function getProviderDocsUrl(
export function shouldShowProviderModelId(
provider: Pick<ProviderTypeInfo, 'showModelId' | 'showModelIdInDevModeOnly'> | undefined,
devModeUnlocked: boolean
devModeUnlocked: boolean,
): boolean {
if (!provider?.showModelId) return false;
if (provider.showModelIdInDevModeOnly && !devModeUnlocked) return false;
@@ -221,7 +221,7 @@ export function shouldShowProviderModelId(
export function resolveProviderModelForSave(
provider: Pick<ProviderTypeInfo, 'defaultModelId' | 'showModelId' | 'showModelIdInDevModeOnly'> | undefined,
modelId: string,
devModeUnlocked: boolean
devModeUnlocked: boolean,
): string | undefined {
if (!shouldShowProviderModelId(provider, devModeUnlocked)) {
return undefined;

76
src/lib/runtime.ts Normal file
View File

@@ -0,0 +1,76 @@
import { IPC_EVENTS, WINDOW_NAMES } from './constants';
import { invokeIpc } from './host-api';
import type { RuntimePlatform, WindowIdentity, WindowName } from '../types/runtime';
function normalizePlatform(platform: string | undefined | null): RuntimePlatform {
const value = platform?.toLowerCase() ?? '';
if (value === 'win32' || value.includes('win')) return 'win32';
if (value === 'darwin' || value.includes('mac')) return 'darwin';
if (value === 'linux') return 'linux';
if (value) return 'unknown';
return 'web';
}
export function hasIpcBridge(): boolean {
return typeof window !== 'undefined' && Boolean(window.api?.invoke);
}
export function detectRuntimePlatform(): RuntimePlatform {
if (typeof window === 'undefined') return 'unknown';
const exposedPlatform = window.api?.platform;
if (exposedPlatform) return normalizePlatform(exposedPlatform);
if (typeof navigator !== 'undefined') {
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
return normalizePlatform(nav.platform || nav.userAgentData?.platform);
}
return window.api ? 'unknown' : 'web';
}
function readWindowIdFromBridge(): string | number | null {
if (typeof window.api?.getCurrentWindowId === 'function') {
return window.api.getCurrentWindowId();
}
return null;
}
export function detectWindowName(windowId?: string | number | null): WindowName {
if (typeof windowId === 'string') {
const normalized = windowId.toLowerCase();
if (normalized.includes('setting')) return WINDOW_NAMES.SETTING;
if (normalized.includes('dialog')) return WINDOW_NAMES.DIALOG;
if (normalized.includes('loading')) return WINDOW_NAMES.LOADING;
}
return WINDOW_NAMES.MAIN;
}
export async function resolveWindowIdentity(): Promise<WindowIdentity> {
const platform = detectRuntimePlatform();
const isElectron = platform !== 'web' && platform !== 'unknown';
if (!hasIpcBridge()) {
return {
platform,
windowId: null,
windowName: WINDOW_NAMES.MAIN,
isElectron,
};
}
let windowId: string | number | null = null;
try {
windowId = readWindowIdFromBridge();
if (windowId === null) {
windowId = await invokeIpc<string | number | null>(IPC_EVENTS.GET_WINDOW_ID);
}
} catch {
windowId = null;
}
return {
platform,
windowId,
windowName: detectWindowName(windowId),
isElectron,
};
}

View File

@@ -1,8 +1,9 @@
import type {
AutomationScript,
ScriptSaveInput,
ScriptRecordingResult,
ScriptExecutionResult,
} from '@lib/script-types';
ScriptSaveInput,
} from './script-types';
export const scriptApi = {
list: (): Promise<AutomationScript[]> => window.api.scriptApi.list(),
@@ -11,10 +12,10 @@ export const scriptApi = {
delete: (id: string): Promise<boolean> => window.api.scriptApi.delete(id),
toggle: (id: string, enabled: boolean): Promise<boolean> => window.api.scriptApi.toggle(id, enabled),
run: (id: string): Promise<ScriptExecutionResult> => window.api.scriptApi.run(id),
startRecording: (url?: string): Promise<{ success: boolean; code?: string; error?: string }> =>
startRecording: (url?: string): Promise<ScriptRecordingResult> =>
window.api.scriptApi.startRecording(url),
stopRecording: (): Promise<{ success: boolean; code?: string; error?: string }> =>
stopRecording: (): Promise<ScriptRecordingResult> =>
window.api.scriptApi.stopRecording(),
codegen: (id: string, url?: string): Promise<{ success: boolean; code?: string; error?: string }> =>
codegen: (id: string, url?: string): Promise<ScriptRecordingResult> =>
window.api.scriptApi.codegen(id, url),
};

View File

@@ -34,6 +34,12 @@ export interface ScriptExecutionResult {
error?: string;
}
export interface ScriptRecordingResult {
success: boolean;
code?: string;
error?: string;
}
export type ScriptRecordingStatus = 'idle' | 'recording' | 'stopped';
export interface ScriptMetaItem {

View File

@@ -1,98 +1,115 @@
import { hostApiFetch } from './host-api';
import type { Skill, MarketplaceSkill } from './skills-types';
import type { MarketplaceSkill, Skill } from './skills-types';
// Mock data for UI development when backend is not ready
export const MOCK_SKILLS: Skill[] = [
{ id: '1password', slug: '1password', name: '1password', description: 'Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in...', enabled: true, icon: '🔐', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/1password' },
{ id: 'apple-notes', slug: 'apple-notes', name: 'apple-notes', description: 'Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes...', enabled: true, icon: '📝', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/apple-notes' },
{ id: 'apple-reminders', slug: 'apple-reminders', name: 'apple-reminders', description: 'Manage Apple Reminders via remindctl CLI (list, add, edit, complete, delete). Supports lists, date filters, and...', enabled: true, icon: '⏰', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/apple-reminders' },
{ id: 'bear-notes', slug: 'bear-notes', name: 'bear-notes', description: 'Create, search, and manage Bear notes via grizzly CLI.', enabled: true, icon: '🐻', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/bear-notes' },
{ id: 'fetch-hacker-news', slug: 'fetch-hacker-news', name: 'fetch-hacker-news', description: 'Fetch top stories from Hacker News.', enabled: false, icon: '📰', version: '1.0.0', isCore: false, isBundled: false, source: 'openclaw-managed', baseDir: '~/.openclaw/skills/fetch-hacker-news' },
{ id: 'github', slug: 'github', name: 'github', description: 'Interact with GitHub issues, pull requests, repositories, and more.', enabled: true, icon: '🐙', version: '1.2.0', isCore: false, isBundled: false, source: 'openclaw-managed', baseDir: '~/.openclaw/skills/github' },
{ id: 'todoist', slug: 'todoist', name: 'todoist', description: 'Manage Todoist tasks and projects.', enabled: true, icon: '✅', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/todoist' },
{ id: 'zotero', slug: 'zotero', name: 'zotero', description: 'Search and manage Zotero references.', enabled: false, icon: '📚', version: '1.0.0', isCore: false, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/zotero' },
{ id: 'browser-use', slug: 'browser-use', name: 'browser-use', description: 'Use a browser to search and navigate the web.', enabled: true, icon: '🌐', version: '2.0.0', isCore: true, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/browser-use' },
{ id: 'memory', slug: 'memory', name: 'memory', description: 'Store and retrieve long-term memories.', enabled: true, icon: '🧠', version: '1.0.0', isCore: true, isBundled: true, source: 'openclaw-bundled', baseDir: '/Applications/ClawX.app/Contents/Resources/openclaw/skills/memory' },
];
type SkillConfigRecord = Record<string, unknown> & {
slug?: string;
name?: string;
description?: string;
enabled?: boolean;
icon?: string;
version?: string;
author?: string;
config?: Record<string, unknown>;
apiKey?: string;
env?: Record<string, string>;
isCore?: boolean;
isBundled?: boolean;
source?: string;
baseDir?: string;
filePath?: string;
};
export const MOCK_MARKETPLACE: MarketplaceSkill[] = [
{ slug: 'notion', name: 'notion', description: 'Read and write pages in Notion workspaces.', version: '1.0.0', author: 'clawhub' },
{ slug: 'linear', name: 'linear', description: 'Manage Linear issues and projects.', version: '1.0.0', author: 'clawhub' },
{ slug: 'slack', name: 'slack', description: 'Send messages and search channels in Slack.', version: '1.0.0', author: 'clawhub' },
{ slug: 'discord', name: 'discord', description: 'Send messages and manage Discord servers.', version: '1.0.0', author: 'clawhub' },
{ slug: 'figma', name: 'figma', description: 'Search and manage Figma files and comments.', version: '1.0.0', author: 'clawhub' },
{ slug: 'spotify', name: 'spotify', description: 'Control Spotify playback and manage playlists.', version: '1.0.0', author: 'clawhub' },
];
type ClawhubListEntry = {
slug?: string;
version?: string;
source?: string;
baseDir?: string;
};
function buildSkillsFromConfigs(configs: Record<string, SkillConfigRecord>): Skill[] {
if (Object.keys(configs).length === 0) {
return [];
}
return Object.entries(configs).map(([key, cfg]) => ({
id: key,
slug: cfg.slug || key,
name: cfg.name || key,
description: cfg.description || '',
enabled: cfg.enabled !== false,
icon: typeof cfg.icon === 'string' ? cfg.icon : undefined,
version: cfg.version || '1.0.0',
author: cfg.author,
config: cfg.config || { apiKey: cfg.apiKey, env: cfg.env },
isCore: cfg.isCore || false,
isBundled: cfg.isBundled ?? true,
source: cfg.source,
baseDir: cfg.baseDir,
filePath: cfg.filePath,
}));
}
function buildSkillsFromClawhubList(entries?: ClawhubListEntry[]): Skill[] {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
return entries.map((item) => ({
id: item.slug || 'unknown-skill',
slug: item.slug,
name: item.slug || 'unknown-skill',
description: 'Waiting for skill metadata from /api/skills/configs.',
enabled: false,
version: item.version || 'unknown',
source: item.source || 'openclaw-managed',
baseDir: item.baseDir,
isCore: false,
isBundled: false,
}));
}
export async function apiFetchSkills(): Promise<Skill[]> {
try {
const configs = await hostApiFetch<Record<string, any>>('/api/skills/configs');
const listResult = await hostApiFetch<{ success: boolean; results?: any[]; error?: string }>('/api/clawhub/list');
const [configsResult, listResult] = await Promise.allSettled([
hostApiFetch<Record<string, SkillConfigRecord>>('/api/skills/configs'),
hostApiFetch<{ success: boolean; results?: ClawhubListEntry[]; error?: string }>('/api/clawhub/list'),
]);
let skills: Skill[] = [];
if (configs && typeof configs === 'object' && Object.keys(configs).length > 0) {
// Support extended config format that includes metadata (for zn-ai without gateway)
skills = Object.entries(configs).map(([key, cfg]: [string, any]) => ({
id: key,
slug: cfg.slug || key,
name: cfg.name || key,
description: cfg.description || '',
enabled: cfg.enabled !== false,
icon: cfg.icon || '📦',
version: cfg.version || '1.0.0',
author: cfg.author,
config: cfg.config || { apiKey: cfg.apiKey, env: cfg.env },
isCore: cfg.isCore || false,
isBundled: cfg.isBundled ?? true,
source: cfg.source,
baseDir: cfg.baseDir,
filePath: cfg.filePath,
}));
} else if (listResult?.success && listResult.results && listResult.results.length > 0) {
skills = listResult.results.map((item: any) => ({
id: item.slug,
slug: item.slug,
name: item.slug,
description: 'Recently installed, initializing...',
enabled: false,
icon: '⌛',
version: item.version || 'unknown',
source: item.source || 'openclaw-managed',
baseDir: item.baseDir,
isCore: false,
isBundled: false,
}));
if (configsResult.status === 'fulfilled' && configsResult.value && typeof configsResult.value === 'object') {
const configuredSkills = buildSkillsFromConfigs(configsResult.value);
if (configuredSkills.length > 0) {
return configuredSkills;
}
if (skills.length === 0) {
return MOCK_SKILLS;
}
return skills;
} catch (error) {
console.error('Failed to fetch skills:', error);
return MOCK_SKILLS;
}
if (listResult.status === 'fulfilled') {
return buildSkillsFromClawhubList(listResult.value?.results);
}
if (configsResult.status === 'fulfilled') {
return [];
}
const configError = configsResult.reason instanceof Error
? configsResult.reason.message
: String(configsResult.reason);
const listError = listResult.status === 'rejected'
? (listResult.reason instanceof Error ? listResult.reason.message : String(listResult.reason))
: null;
throw new Error(listError ? `${configError}; ${listError}` : configError);
}
export async function apiSearchSkills(query: string): Promise<MarketplaceSkill[]> {
try {
const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/clawhub/search', {
method: 'POST',
body: JSON.stringify({ query }),
});
if (result?.success && result.results && result.results.length > 0) {
return result.results;
}
if (!query) return MOCK_MARKETPLACE;
const q = query.toLowerCase();
return MOCK_MARKETPLACE.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
} catch (error) {
console.error('Search error:', error);
if (!query) return MOCK_MARKETPLACE;
const q = query.toLowerCase();
return MOCK_MARKETPLACE.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/clawhub/search', {
method: 'POST',
body: JSON.stringify({ query }),
});
if (!result?.success) {
throw new Error(result?.error || 'ClawHub search failed');
}
return Array.isArray(result.results) ? result.results : [];
}
export async function apiInstallSkill(slug: string, version?: string): Promise<void> {
@@ -100,6 +117,7 @@ export async function apiInstallSkill(slug: string, version?: string): Promise<v
method: 'POST',
body: JSON.stringify({ slug, version }),
});
if (!result?.success) {
throw new Error(result?.error || 'Install failed');
}
@@ -110,6 +128,7 @@ export async function apiUninstallSkill(slug: string): Promise<void> {
method: 'POST',
body: JSON.stringify({ slug }),
});
if (!result?.success) {
throw new Error(result?.error || 'Uninstall failed');
}
@@ -117,12 +136,13 @@ export async function apiUninstallSkill(slug: string): Promise<void> {
export async function apiUpdateSkillConfig(
skillKey: string,
config: { apiKey?: string; env?: Record<string, string>; enabled?: boolean }
config: { apiKey?: string; env?: Record<string, string>; enabled?: boolean },
): Promise<void> {
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/skills/config', {
method: 'PUT',
body: JSON.stringify({ skillKey, ...config }),
});
if (!result?.success) {
throw new Error(result?.error || 'Save failed');
}
@@ -153,7 +173,7 @@ export async function apiGetSkillsDir(): Promise<string> {
const result = await hostApiFetch<{ success: boolean; dir?: string; error?: string }>('/api/clawhub/skills-dir');
if (result?.success && result.dir) return result.dir;
} catch {
// fallback
// Fallback to the default local path.
}
return '~/.zn-ai/skills';
}
@@ -167,8 +187,8 @@ export async function apiOpenSkillsDir(): Promise<void> {
});
if (result?.success) return;
throw new Error(result?.error || 'Open failed');
} catch (error) {
} catch {
await navigator.clipboard.writeText(dir);
throw new Error('Path copied to clipboard: ' + dir);
throw new Error(`Path copied to clipboard: ${dir}`);
}
}

View File

@@ -1,11 +1,3 @@
/**
* Skill Type Definitions
* Types for skills/plugins
*/
/**
* Skill data structure
*/
export interface Skill {
id: string;
slug?: string;
@@ -25,23 +17,6 @@ export interface Skill {
filePath?: string;
}
/**
* Skill bundle (preset skill collection)
*/
export interface SkillBundle {
id: string;
name: string;
nameZh: string;
description: string;
descriptionZh: string;
icon: string;
skills: string[];
recommended?: boolean;
}
/**
* Marketplace skill data
*/
export interface MarketplaceSkill {
slug: string;
name: string;
@@ -51,18 +26,3 @@ export interface MarketplaceSkill {
downloads?: number;
stars?: number;
}
/**
* Skill configuration schema
*/
export interface SkillConfigSchema {
type: 'object';
properties: Record<string, {
type: 'string' | 'number' | 'boolean' | 'array';
title?: string;
description?: string;
default?: unknown;
enum?: unknown[];
}>;
required?: string[];
}

View File

@@ -2,7 +2,7 @@ import type { Skill } from './skills-types';
export function resolveSkillSourceLabel(
skill: Skill,
t: (key: string, defaultValue?: string) => string
t: (key: string, defaultValue?: string) => string,
): string {
const source = (skill.source || '').trim().toLowerCase();
if (!source) {

51
src/lib/theme.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { ResolvedThemeMode, ThemeMode } from '../types/runtime';
export function detectSystemTheme(): ResolvedThemeMode {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return 'light';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function resolveAppliedTheme(
themeMode: ThemeMode,
systemTheme: ResolvedThemeMode = detectSystemTheme(),
): ResolvedThemeMode {
return themeMode === 'system' ? systemTheme : themeMode;
}
export function applyThemeModeToDocument(
themeMode: ThemeMode,
systemTheme: ResolvedThemeMode = detectSystemTheme(),
): ResolvedThemeMode {
const appliedTheme = resolveAppliedTheme(themeMode, systemTheme);
if (typeof document !== 'undefined') {
const root = document.documentElement;
root.classList.toggle('dark', appliedTheme === 'dark');
root.dataset.theme = appliedTheme;
root.style.colorScheme = appliedTheme;
}
return appliedTheme;
}
export function watchSystemTheme(onChange: (theme: ResolvedThemeMode) => void): () => void {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return () => {};
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (event: MediaQueryListEvent) => {
onChange(event.matches ? 'dark' : 'light');
};
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
mediaQuery.addListener(handler);
return () => mediaQuery.removeListener(handler);
}

View File

@@ -1,49 +0,0 @@
import type { Task } from './task-types';
import { WINDOW_NAMES, CONFIG_KEYS } from './constants';
export type WindowNames = `${WINDOW_NAMES}`;
export type ConfigKeys = `${CONFIG_KEYS}`;
export interface IConfig {
// 主题模式配置
[CONFIG_KEYS.THEME_MODE]: ThemeMode;
// 高亮色
[CONFIG_KEYS.PRIMARY_COLOR]: string;
// 语言
[CONFIG_KEYS.LANGUAGE]: 'zh' | 'en';
// 字体大小
[CONFIG_KEYS.FONT_SIZE]: number;
// 关闭时最小化到托盘
[CONFIG_KEYS.MINIMIZE_TO_TRAY]: boolean;
// provider 配置 JSON
[CONFIG_KEYS.PROVIDER]?: string;
// 默认模型
[CONFIG_KEYS.DEFAULT_MODEL]?: string | null;
// 自动检查更新
[CONFIG_KEYS.AUTO_CHECK_UPDATE]?: boolean;
// 自动下载更新
[CONFIG_KEYS.AUTO_DOWNLOAD_UPDATE]?: boolean;
// 选中的渠道
[CONFIG_KEYS.SELECTED_CHANNELS]: Array<{ id: string; channelName: string; channelUrl: string }>;
// 图片缓存
[CONFIG_KEYS.IMAGE_CACHE]: Array<[string, any]>;
// 任务列表
[CONFIG_KEYS.TASK_LIST]?: Task[];
}
export interface Provider {
id: number;
name: string;
visible?: boolean;
title?: string;
type?: 'OpenAI';
openAISetting?: string;
createdAt: number;
updatedAt: number;
models: string[];
}
export interface OpenAISetting {
baseURL?: string;
apiKey?: string;
}

View File

@@ -1,94 +0,0 @@
import type { OpenAISetting } from './types'
import { encode, decode } from 'js-base64'
/**
* 防抖函数
* @param fn 需要执行的函数
* @param delay 延迟时间(毫秒)
* @returns 防抖处理后的函数
*/
export function debounce<T extends (...args: any[]) => any>(fn: T, delay: number): (...args: Parameters<T>) => void {
let timer: NodeJS.Timeout | null = null;
return function (this: any, ...args: Parameters<T>) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
/**
* 节流函数
* @param fn 需要执行的函数
* @param interval 间隔时间(毫秒)
* @returns 节流处理后的函数
*/
export function throttle<T extends (...args: any[]) => any>(fn: T, interval: number): (...args: Parameters<T>) => void {
let lastTime = 0;
return function (this: any, ...args: Parameters<T>) {
const now = Date.now();
if (now - lastTime >= interval) {
fn.apply(this, args);
lastTime = now;
}
};
}
export function cloneDeep<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => cloneDeep(item)) as T;
}
const clone = Object.assign({}, obj);
for (const key in clone) {
if (Object.prototype.hasOwnProperty.call(clone, key)) {
clone[key] = cloneDeep(clone[key]);
}
}
return clone;
}
export function simpleCloneDeep<T>(obj: T): T {
try {
return JSON.parse(JSON.stringify(obj));
} catch (error) {
console.error('simpleCloneDeep failed:', error);
return obj;
}
}
export function stringifyOpenAISetting(setting: OpenAISetting) {
try {
return encode(JSON.stringify(setting));
} catch (error) {
console.error('stringifyOpenAISetting failed:', error);
return '';
}
}
export function parseOpenAISetting(setting: string): OpenAISetting {
try {
return JSON.parse(decode(setting));
} catch (error) {
console.error('parseOpenAISetting failed:', error);
return {} as OpenAISetting;
}
}
export function uniqueByKey<T extends Record<string, any>>(arr: T[], key: keyof T): T[] {
const seen = new Map<any, boolean>();
return arr.filter(item => {
const keyValue = item[key];
if (seen.has(keyValue)) {
return false;
}
seen.set(keyValue, true);
return true;
});
}