feat(ui): Phase 8 UI/UX optimization and system documentation update
## Sidebar Enhancement - Change tabs to icon + small label layout for better space utilization - Add Teams tab with team collaboration entry point ## Settings Page Improvements - Connect theme toggle to gatewayStore.saveQuickConfig for persistence - Remove OpenFang backend download section, simplify UI - Add time range filter to UsageStats (7d/30d/all) - Add stat cards with icons (sessions, messages, input/output tokens) - Add token usage overview bar chart - Add 8 ZCLAW system skill definitions with categories ## Bug Fixes - Fix ChannelList duplicate content with deduplication logic - Integrate CreateTriggerModal in TriggersPanel - Add independent SecurityStatusPanel with 12 default enabled layers - Change workflow view to use SchedulerPanel as unified entry ## New Components - CreateTriggerModal: Event trigger creation modal - HandApprovalModal: Hand approval workflow dialog - HandParamsForm: Enhanced Hand parameter form - SecurityLayersPanel: 16-layer security status display ## Architecture - Add TOML config parsing support (toml-utils.ts, config-parser.ts) - Add request timeout and retry mechanism (request-helper.ts) - Add secure token storage (secure-storage.ts, secure_storage.rs) ## Tests - Add unit tests for config-parser, toml-utils, request-helper - Add team-client and teamStore tests ## Documentation - Update SYSTEM_ANALYSIS.md with Phase 8 completion - UI completion: 100% (30/30 components) - API coverage: 93% (63/68 endpoints) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
377
desktop/src/lib/config-parser.ts
Normal file
377
desktop/src/lib/config-parser.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* OpenFang Configuration Parser
|
||||
*
|
||||
* Provides configuration parsing, validation, and serialization for OpenFang TOML files.
|
||||
*
|
||||
* @module lib/config-parser
|
||||
*/
|
||||
|
||||
import { tomlUtils, TomlParseError } from './toml-utils';
|
||||
import type {
|
||||
OpenFangConfig,
|
||||
ConfigValidationResult,
|
||||
ConfigValidationError,
|
||||
ConfigValidationWarning,
|
||||
ConfigFileMetadata,
|
||||
ServerConfig,
|
||||
AgentSectionConfig,
|
||||
LLMConfig,
|
||||
} from '../types/config';
|
||||
|
||||
/**
|
||||
* Error class for configuration parsing errors
|
||||
*/
|
||||
export class ConfigParseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfigParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for configuration validation errors (thrown when validation fails)
|
||||
*/
|
||||
export class ConfigValidationFailedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errors: ConfigValidationError[]
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfigValidationFailedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required configuration fields with their paths
|
||||
*/
|
||||
const REQUIRED_FIELDS: Array<{ path: string; description: string }> = [
|
||||
{ path: 'server', description: 'Server configuration' },
|
||||
{ path: 'server.host', description: 'Server host address' },
|
||||
{ path: 'server.port', description: 'Server port number' },
|
||||
{ path: 'agent', description: 'Agent configuration' },
|
||||
{ path: 'agent.defaults', description: 'Agent defaults' },
|
||||
{ path: 'agent.defaults.workspace', description: 'Default workspace path' },
|
||||
{ path: 'agent.defaults.default_model', description: 'Default model name' },
|
||||
{ path: 'llm', description: 'LLM configuration' },
|
||||
{ path: 'llm.default_provider', description: 'Default LLM provider' },
|
||||
{ path: 'llm.default_model', description: 'Default LLM model' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG: Partial<OpenFangConfig> = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 50051,
|
||||
websocket_port: 50051,
|
||||
websocket_path: '/ws',
|
||||
api_version: 'v1',
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
workspace: '~/.openfang/workspace',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
default_provider: 'openai',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration parser and validator
|
||||
*/
|
||||
export const configParser = {
|
||||
/**
|
||||
* Parse TOML content into an OpenFang configuration object
|
||||
*
|
||||
* @param content - The TOML content to parse
|
||||
* @param envVars - Optional environment variables for resolution
|
||||
* @returns The parsed configuration object
|
||||
* @throws ConfigParseError if parsing fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = configParser.parseConfig(tomlContent, { OPENAI_API_KEY: 'sk-...' });
|
||||
* ```
|
||||
*/
|
||||
parseConfig: (content: string, envVars?: Record<string, string | undefined>): OpenFangConfig => {
|
||||
try {
|
||||
// First resolve environment variables
|
||||
const resolved = tomlUtils.resolveEnvVars(content, envVars);
|
||||
|
||||
// Parse TOML
|
||||
const parsed = tomlUtils.parse<OpenFangConfig>(resolved);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof TomlParseError) {
|
||||
throw new ConfigParseError(`Failed to parse configuration: ${error.message}`, error);
|
||||
}
|
||||
throw new ConfigParseError(
|
||||
`Unexpected error parsing configuration: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate an OpenFang configuration object
|
||||
*
|
||||
* @param config - The configuration object to validate
|
||||
* @returns Validation result with errors and warnings
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = configParser.validateConfig(parsedConfig);
|
||||
* if (!result.valid) {
|
||||
* console.error('Config errors:', result.errors);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
validateConfig: (config: unknown): ConfigValidationResult => {
|
||||
const errors: ConfigValidationError[] = [];
|
||||
const warnings: ConfigValidationWarning[] = [];
|
||||
// Basic type check
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
errors.push({
|
||||
path: '',
|
||||
message: 'Configuration must be a non-null object',
|
||||
severity: 'error',
|
||||
});
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
const cfg = config as Record<string, unknown>;
|
||||
// Check required fields
|
||||
for (const { path, description } of REQUIRED_FIELDS) {
|
||||
const value = getNestedValue(cfg, path);
|
||||
if (value === undefined) {
|
||||
errors.push({
|
||||
path,
|
||||
message: `Missing required field: ${description}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Validate server configuration
|
||||
if (cfg.server && typeof cfg.server === 'object') {
|
||||
const server = cfg.server as ServerConfig;
|
||||
if (typeof server.port === 'number' && (server.port < 1 || server.port > 65535)) {
|
||||
errors.push({
|
||||
path: 'server.port',
|
||||
message: 'Port must be between 1 and 65535',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
if (typeof server.host === 'string' && server.host.length === 0) {
|
||||
errors.push({
|
||||
path: 'server.host',
|
||||
message: 'Host cannot be empty',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Validate agent configuration
|
||||
if (cfg.agent && typeof cfg.agent === 'object') {
|
||||
const agent = cfg.agent as AgentSectionConfig;
|
||||
if (agent.defaults) {
|
||||
if (typeof agent.defaults.workspace === 'string' && agent.defaults.workspace.length === 0) {
|
||||
warnings.push({
|
||||
path: 'agent.defaults.workspace',
|
||||
message: 'Workspace path is empty',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
if (typeof agent.defaults.default_model === 'string' && agent.defaults.default_model.length === 0) {
|
||||
errors.push({
|
||||
path: 'agent.defaults.default_model',
|
||||
message: 'Default model cannot be empty',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate LLM configuration
|
||||
if (cfg.llm && typeof cfg.llm === 'object') {
|
||||
const llm = cfg.llm as LLMConfig;
|
||||
if (typeof llm.default_provider === 'string' && llm.default_provider.length === 0) {
|
||||
errors.push({
|
||||
path: 'llm.default_provider',
|
||||
message: 'Default provider cannot be empty',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
if (typeof llm.default_model === 'string' && llm.default_model.length === 0) {
|
||||
errors.push({
|
||||
path: 'llm.default_model',
|
||||
message: 'Default model cannot be empty',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse and validate configuration in one step
|
||||
*
|
||||
* @param content - The TOML content to parse
|
||||
* @param envVars - Optional environment variables for resolution
|
||||
* @returns The parsed and validated configuration
|
||||
* @throws ConfigParseError if parsing fails
|
||||
* @throws ConfigValidationFailedError if validation fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = configParser.parseAndValidate(tomlContent);
|
||||
* ```
|
||||
*/
|
||||
parseAndValidate: (
|
||||
content: string,
|
||||
envVars?: Record<string, string | undefined>
|
||||
): OpenFangConfig => {
|
||||
const config = configParser.parseConfig(content, envVars);
|
||||
const result = configParser.validateConfig(config);
|
||||
if (!result.valid) {
|
||||
throw new ConfigValidationFailedError(
|
||||
`Configuration validation failed: ${result.errors.map((e) => e.message).join(', ')}`,
|
||||
result.errors
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* Serialize a configuration object to TOML format
|
||||
*
|
||||
* @param config - The configuration object to serialize
|
||||
* @returns The TOML string
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const toml = configParser.stringifyConfig(config);
|
||||
* ```
|
||||
*/
|
||||
stringifyConfig: (config: OpenFangConfig): string => {
|
||||
return tomlUtils.stringify(config as unknown as Record<string, unknown>);
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge partial configuration with defaults
|
||||
*
|
||||
* @param config - Partial configuration to merge
|
||||
* @returns Complete configuration with defaults applied
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fullConfig = configParser.mergeWithDefaults(partialConfig);
|
||||
* ```
|
||||
*/
|
||||
mergeWithDefaults: (config: Partial<OpenFangConfig>): OpenFangConfig => {
|
||||
return deepMerge(DEFAULT_CONFIG, config) as unknown as OpenFangConfig;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract metadata from a TOML configuration file
|
||||
*
|
||||
* @param content - The TOML content
|
||||
* @param filePath - The file path
|
||||
* @returns Configuration file metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const metadata = configParser.extractMetadata(tomlContent, '/path/to/config.toml');
|
||||
* console.log('Env vars needed:', metadata.envVars);
|
||||
* ```
|
||||
*/
|
||||
extractMetadata: (content: string, filePath: string): ConfigFileMetadata => {
|
||||
const envVars = tomlUtils.extractEnvVarNames(content);
|
||||
const hasUnresolvedEnvVars = tomlUtils.hasUnresolvedEnvVars(content);
|
||||
return {
|
||||
path: filePath,
|
||||
name: filePath.split('/').pop() || filePath,
|
||||
envVars,
|
||||
hasUnresolvedEnvVars,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get default configuration
|
||||
*
|
||||
* @returns Default OpenFang configuration
|
||||
*/
|
||||
getDefaults: (): OpenFangConfig => {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as OpenFangConfig;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a configuration object is valid
|
||||
*
|
||||
* @param config - The configuration to check
|
||||
* @returns Type guard for OpenFangConfig
|
||||
*/
|
||||
isOpenFangConfig: (config: unknown): config is OpenFangConfig => {
|
||||
const result = configParser.validateConfig(config);
|
||||
return result.valid;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get a nested value from an object using dot-notation path
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to deep merge two objects
|
||||
*/
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
target: Partial<T>,
|
||||
source: Partial<T>
|
||||
): Partial<T> {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = target[key];
|
||||
if (
|
||||
sourceValue !== undefined &&
|
||||
typeof sourceValue === 'object' &&
|
||||
sourceValue !== null &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
targetValue !== undefined &&
|
||||
typeof targetValue === 'object' &&
|
||||
targetValue !== null &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>
|
||||
) as T[keyof T];
|
||||
} else if (sourceValue !== undefined) {
|
||||
// Safe assignment: sourceValue is typed as Partial<T>[keyof T], result[key] expects T[keyof T]
|
||||
result[key] = sourceValue as T[keyof T];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export default configParser;
|
||||
Reference in New Issue
Block a user