mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: add Accordion component for UI and Zustand store for coordinator management
- Implemented Accordion component using Radix UI for collapsible sections. - Created Zustand store to manage coordinator execution state, command chains, logs, and interactive questions. - Added validation tests for CLI settings type definitions, ensuring type safety and correct behavior of helper functions.
This commit is contained in:
@@ -126,11 +126,16 @@ export function saveEndpointSettings(request: SaveEndpointRequest): SettingsOper
|
||||
// Usage: ccw cli -p "..." --tool <name> --mode analysis
|
||||
try {
|
||||
const projectDir = os.homedir(); // Use home dir as base for global config
|
||||
// Merge user-provided tags with cli-wrapper tag for proper type registration
|
||||
const userTags = request.settings.tags || [];
|
||||
const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag
|
||||
addClaudeCustomEndpoint(projectDir, {
|
||||
id: endpointId,
|
||||
name: request.name,
|
||||
enabled: request.enabled ?? true,
|
||||
tags: ['cli-wrapper'] // cli-wrapper tag -> registers as type: 'cli-wrapper'
|
||||
tags,
|
||||
availableModels: request.settings.availableModels,
|
||||
settingsFile: request.settings.settingsFile
|
||||
});
|
||||
console.log(`[CliSettings] Synced endpoint ${endpointId} to cli-tools.json tools (cli-wrapper)`);
|
||||
} catch (syncError) {
|
||||
@@ -306,11 +311,17 @@ export function toggleEndpointEnabled(endpointId: string, enabled: boolean): Set
|
||||
// Sync enabled status with cli-tools.json tools (cli-wrapper type)
|
||||
try {
|
||||
const projectDir = os.homedir();
|
||||
// Load full settings to get tags
|
||||
const endpoint = loadEndpointSettings(endpointId);
|
||||
const userTags = endpoint?.settings.tags || [];
|
||||
const tags = [...new Set([...userTags, 'cli-wrapper'])]; // Dedupe and ensure cli-wrapper tag
|
||||
addClaudeCustomEndpoint(projectDir, {
|
||||
id: endpointId,
|
||||
name: metadata.name,
|
||||
enabled: enabled,
|
||||
tags: ['cli-wrapper'] // cli-wrapper tag -> registers as type: 'cli-wrapper'
|
||||
tags,
|
||||
availableModels: endpoint?.settings.availableModels,
|
||||
settingsFile: endpoint?.settings.settingsFile
|
||||
});
|
||||
console.log(`[CliSettings] Synced endpoint ${endpointId} enabled=${enabled} to cli-tools.json tools`);
|
||||
} catch (syncError) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSyn
|
||||
import { join, isAbsolute, extname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getMemoryStore } from '../memory-store.js';
|
||||
import { getCoreMemoryStore } from '../core-memory-store.js';
|
||||
import { executeCliTool } from '../../tools/cli-executor.js';
|
||||
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
|
||||
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
|
||||
@@ -87,6 +88,147 @@ function calculateQualityScore(text: string): number {
|
||||
export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
|
||||
|
||||
// API: Memory Module - Get all memories (core memory list)
|
||||
if (pathname === '/api/memory' && req.method === 'GET') {
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(projectPath);
|
||||
const memories = store.getMemories({ archived: false, limit: 100 });
|
||||
|
||||
// Calculate total size
|
||||
const totalSize = memories.reduce((sum, m) => sum + (m.content?.length || 0), 0);
|
||||
|
||||
// Count CLAUDE.md files (assuming memories with source='CLAUDE.md')
|
||||
const claudeMdCount = memories.filter(m => m.metadata?.includes('CLAUDE.md') || m.content?.includes('# Claude Instructions')).length;
|
||||
|
||||
// Transform to frontend format
|
||||
const formattedMemories = memories.map(m => ({
|
||||
id: m.id,
|
||||
content: m.content,
|
||||
createdAt: m.created_at,
|
||||
updatedAt: m.updated_at,
|
||||
source: m.metadata || undefined,
|
||||
tags: [], // TODO: Extract tags from metadata if available
|
||||
size: m.content?.length || 0
|
||||
}));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
memories: formattedMemories,
|
||||
totalSize,
|
||||
claudeMdCount
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Memory Module - Create new memory
|
||||
if (pathname === '/api/memory' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { content, tags, path: projectPath } = body;
|
||||
|
||||
if (!content) {
|
||||
return { error: 'content is required', status: 400 };
|
||||
}
|
||||
|
||||
const basePath = projectPath || initialPath;
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(basePath);
|
||||
const memory = store.upsertMemory({ content });
|
||||
|
||||
// Broadcast update event
|
||||
broadcastToClients({
|
||||
type: 'CORE_MEMORY_CREATED',
|
||||
payload: {
|
||||
memoryId: memory.id,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: memory.id,
|
||||
content: memory.content,
|
||||
createdAt: memory.created_at,
|
||||
updatedAt: memory.updated_at,
|
||||
source: memory.metadata || undefined,
|
||||
tags: tags || [],
|
||||
size: memory.content?.length || 0
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { error: (error as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Memory Module - Update memory
|
||||
if (pathname.match(/^\/api\/memory\/[^\/]+$/) && req.method === 'PATCH') {
|
||||
const memoryId = pathname.replace('/api/memory/', '');
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { content, tags, path: projectPath } = body;
|
||||
const basePath = projectPath || initialPath;
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(basePath);
|
||||
const memory = store.upsertMemory({ id: memoryId, content });
|
||||
|
||||
// Broadcast update event
|
||||
broadcastToClients({
|
||||
type: 'CORE_MEMORY_UPDATED',
|
||||
payload: {
|
||||
memoryId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: memory.id,
|
||||
content: memory.content,
|
||||
createdAt: memory.created_at,
|
||||
updatedAt: memory.updated_at,
|
||||
source: memory.metadata || undefined,
|
||||
tags: tags || [],
|
||||
size: memory.content?.length || 0
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { error: (error as Error).message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Memory Module - Delete memory
|
||||
if (pathname.match(/^\/api\/memory\/[^\/]+$/) && req.method === 'DELETE') {
|
||||
const memoryId = pathname.replace('/api/memory/', '');
|
||||
const projectPath = url.searchParams.get('path') || initialPath;
|
||||
|
||||
try {
|
||||
const store = getCoreMemoryStore(projectPath);
|
||||
store.deleteMemory(memoryId);
|
||||
|
||||
// Broadcast update event
|
||||
broadcastToClients({
|
||||
type: 'CORE_MEMORY_DELETED',
|
||||
payload: {
|
||||
memoryId,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
} catch (error: unknown) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Memory Module - Track entity access
|
||||
if (pathname === '/api/memory/track' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
|
||||
@@ -562,8 +562,8 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleClaudeRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Memory routes (/api/memory/*)
|
||||
if (pathname.startsWith('/api/memory/')) {
|
||||
// Memory routes (/api/memory and /api/memory/*)
|
||||
if (pathname === '/api/memory' || pathname.startsWith('/api/memory/')) {
|
||||
if (await handleMemoryRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
|
||||
@@ -429,3 +429,170 @@ export function broadcastOrchestratorLog(execId: string, log: Omit<ExecutionLog,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator WebSocket message types
|
||||
*/
|
||||
export type CoordinatorMessageType =
|
||||
| 'COORDINATOR_STATE_UPDATE'
|
||||
| 'COORDINATOR_COMMAND_STARTED'
|
||||
| 'COORDINATOR_COMMAND_COMPLETED'
|
||||
| 'COORDINATOR_COMMAND_FAILED'
|
||||
| 'COORDINATOR_LOG_ENTRY'
|
||||
| 'COORDINATOR_QUESTION_ASKED'
|
||||
| 'COORDINATOR_ANSWER_RECEIVED';
|
||||
|
||||
/**
|
||||
* Coordinator State Update - fired when coordinator execution status changes
|
||||
*/
|
||||
export interface CoordinatorStateUpdateMessage {
|
||||
type: 'COORDINATOR_STATE_UPDATE';
|
||||
executionId: string;
|
||||
status: 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
currentNodeId?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Command Started - fired when a command node begins execution
|
||||
*/
|
||||
export interface CoordinatorCommandStartedMessage {
|
||||
type: 'COORDINATOR_COMMAND_STARTED';
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
commandName: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Command Completed - fired when a command node finishes successfully
|
||||
*/
|
||||
export interface CoordinatorCommandCompletedMessage {
|
||||
type: 'COORDINATOR_COMMAND_COMPLETED';
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
result?: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Command Failed - fired when a command node encounters an error
|
||||
*/
|
||||
export interface CoordinatorCommandFailedMessage {
|
||||
type: 'COORDINATOR_COMMAND_FAILED';
|
||||
executionId: string;
|
||||
nodeId: string;
|
||||
error: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Log Entry - fired for execution log entries
|
||||
*/
|
||||
export interface CoordinatorLogEntryMessage {
|
||||
type: 'COORDINATOR_LOG_ENTRY';
|
||||
executionId: string;
|
||||
log: {
|
||||
level: 'info' | 'warn' | 'error' | 'debug' | 'success';
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
source?: 'system' | 'node' | 'user';
|
||||
timestamp: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Question Asked - fired when coordinator needs user input
|
||||
*/
|
||||
export interface CoordinatorQuestionAskedMessage {
|
||||
type: 'COORDINATOR_QUESTION_ASKED';
|
||||
executionId: string;
|
||||
question: {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: 'text' | 'single' | 'multi' | 'yes_no';
|
||||
options?: string[];
|
||||
required: boolean;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator Answer Received - fired when user submits an answer
|
||||
*/
|
||||
export interface CoordinatorAnswerReceivedMessage {
|
||||
type: 'COORDINATOR_ANSWER_RECEIVED';
|
||||
executionId: string;
|
||||
questionId: string;
|
||||
answer: string | string[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for Coordinator messages (without timestamp - added automatically)
|
||||
*/
|
||||
export type CoordinatorMessage =
|
||||
| Omit<CoordinatorStateUpdateMessage, 'timestamp'>
|
||||
| Omit<CoordinatorCommandStartedMessage, 'timestamp'>
|
||||
| Omit<CoordinatorCommandCompletedMessage, 'timestamp'>
|
||||
| Omit<CoordinatorCommandFailedMessage, 'timestamp'>
|
||||
| Omit<CoordinatorLogEntryMessage, 'timestamp'>
|
||||
| Omit<CoordinatorQuestionAskedMessage, 'timestamp'>
|
||||
| Omit<CoordinatorAnswerReceivedMessage, 'timestamp'>;
|
||||
|
||||
/**
|
||||
* Coordinator-specific broadcast with throttling
|
||||
* Throttles COORDINATOR_STATE_UPDATE messages to avoid flooding clients
|
||||
*/
|
||||
let lastCoordinatorBroadcast = 0;
|
||||
const COORDINATOR_BROADCAST_THROTTLE = 1000; // 1 second
|
||||
|
||||
/**
|
||||
* Broadcast coordinator update with throttling
|
||||
* STATE_UPDATE messages are throttled to 1 per second
|
||||
* Other message types are sent immediately
|
||||
*/
|
||||
export function broadcastCoordinatorUpdate(message: CoordinatorMessage): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle COORDINATOR_STATE_UPDATE to reduce WebSocket traffic
|
||||
if (message.type === 'COORDINATOR_STATE_UPDATE' && now - lastCoordinatorBroadcast < COORDINATOR_BROADCAST_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'COORDINATOR_STATE_UPDATE') {
|
||||
lastCoordinatorBroadcast = now;
|
||||
}
|
||||
|
||||
broadcastToClients({
|
||||
...message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast coordinator log entry (no throttling)
|
||||
* Used for streaming real-time coordinator logs to Dashboard
|
||||
*/
|
||||
export function broadcastCoordinatorLog(
|
||||
executionId: string,
|
||||
log: {
|
||||
level: 'info' | 'warn' | 'error' | 'debug' | 'success';
|
||||
message: string;
|
||||
nodeId?: string;
|
||||
source?: 'system' | 'node' | 'user';
|
||||
}
|
||||
): void {
|
||||
broadcastToClients({
|
||||
type: 'COORDINATOR_LOG_ENTRY',
|
||||
executionId,
|
||||
log: {
|
||||
...log,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -860,7 +860,7 @@ export function removeClaudeApiEndpoint(
|
||||
*/
|
||||
export function addClaudeCustomEndpoint(
|
||||
projectDir: string,
|
||||
endpoint: { id: string; name: string; enabled: boolean; tags?: string[] }
|
||||
endpoint: { id: string; name: string; enabled: boolean; tags?: string[]; availableModels?: string[]; settingsFile?: string }
|
||||
): ClaudeCliToolsConfig {
|
||||
const config = loadClaudeCliTools(projectDir);
|
||||
|
||||
@@ -869,7 +869,9 @@ export function addClaudeCustomEndpoint(
|
||||
config.tools[endpoint.name] = {
|
||||
enabled: endpoint.enabled,
|
||||
tags: endpoint.tags.filter(t => t !== 'cli-wrapper'),
|
||||
type: 'cli-wrapper'
|
||||
type: 'cli-wrapper',
|
||||
...(endpoint.availableModels && { availableModels: endpoint.availableModels }),
|
||||
...(endpoint.settingsFile && { settingsFile: endpoint.settingsFile })
|
||||
};
|
||||
} else {
|
||||
// API endpoint tool
|
||||
@@ -877,7 +879,9 @@ export function addClaudeCustomEndpoint(
|
||||
enabled: endpoint.enabled,
|
||||
tags: [],
|
||||
type: 'api-endpoint',
|
||||
id: endpoint.id
|
||||
id: endpoint.id,
|
||||
...(endpoint.availableModels && { availableModels: endpoint.availableModels }),
|
||||
...(endpoint.settingsFile && { settingsFile: endpoint.settingsFile })
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface ClaudeCliSettings {
|
||||
model?: 'opus' | 'sonnet' | 'haiku' | string;
|
||||
/** 是否包含 co-authored-by */
|
||||
includeCoAuthoredBy?: boolean;
|
||||
/** CLI工具标签 (用于标签路由) */
|
||||
tags?: string[];
|
||||
/** 可用模型列表 (显示在下拉菜单中) */
|
||||
availableModels?: string[];
|
||||
/** 外部配置文件路径 (用于 builtin claude 工具) */
|
||||
settingsFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +110,9 @@ export function createDefaultSettings(): ClaudeCliSettings {
|
||||
DISABLE_AUTOUPDATER: '1'
|
||||
},
|
||||
model: 'sonnet',
|
||||
includeCoAuthoredBy: false
|
||||
includeCoAuthoredBy: false,
|
||||
tags: [],
|
||||
availableModels: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +131,18 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
|
||||
return false;
|
||||
}
|
||||
|
||||
// 深层验证:env 内部所有值必须是 string 或 undefined
|
||||
const envObj = s.env as Record<string, unknown>;
|
||||
for (const key in envObj) {
|
||||
if (Object.prototype.hasOwnProperty.call(envObj, key)) {
|
||||
const value = envObj[key];
|
||||
// 允许 undefined 或 string,其他类型(包括 null)都拒绝
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// model 可选,但如果存在必须是字符串
|
||||
if (s.model !== undefined && typeof s.model !== 'string') {
|
||||
return false;
|
||||
@@ -133,5 +153,20 @@ export function validateSettings(settings: unknown): settings is ClaudeCliSettin
|
||||
return false;
|
||||
}
|
||||
|
||||
// tags 可选,但如果存在必须是数组
|
||||
if (s.tags !== undefined && !Array.isArray(s.tags)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// availableModels 可选,但如果存在必须是数组
|
||||
if (s.availableModels !== undefined && !Array.isArray(s.availableModels)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// settingsFile 可选,但如果存在必须是字符串
|
||||
if (s.settingsFile !== undefined && typeof s.settingsFile !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user