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:
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
373
src/lib/index.ts
373
src/lib/index.ts
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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
76
src/lib/runtime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
51
src/lib/theme.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user