feat(a2ui): Implement A2UI backend with question handling and WebSocket support

- Added A2UITypes for defining question structures and answers.
- Created A2UIWebSocketHandler for managing WebSocket connections and message handling.
- Developed ask-question tool for interactive user questions via A2UI.
- Introduced platformUtils for platform detection and shell command handling.
- Centralized TypeScript types in index.ts for better organization.
- Implemented compatibility checks for hook templates based on platform requirements.
This commit is contained in:
catlog22
2026-01-31 15:27:12 +08:00
parent 4e009bb03a
commit 715ef12c92
163 changed files with 19495 additions and 715 deletions

View File

@@ -3,9 +3,11 @@
// ========================================
// Typed fetch functions for API communication with CSRF token handling
import type { SessionMetadata, TaskData } from '../types/store';
import type { SessionMetadata, TaskData, IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion } from '../types/store';
// Re-export types for backward compatibility
export type { IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion };
// ========== Types ==========
/**
* Raw backend session data structure matching the backend API response.
@@ -430,6 +432,33 @@ export async function removeRecentPath(path: string): Promise<string[]> {
return data.paths;
}
/**
* Switch workspace response
*/
export interface SwitchWorkspaceResponse {
projectPath: string;
recentPaths: string[];
activeSessions: SessionMetadata[];
archivedSessions: SessionMetadata[];
statistics: DashboardStats;
}
/**
* Remove recent path response
*/
export interface RemoveRecentPathResponse {
paths: string[];
}
/**
* Fetch data for path response
*/
export interface FetchDataForPathResponse {
projectOverview?: ProjectOverview | null;
sessions?: SessionsResponse;
statistics?: DashboardStats;
}
/**
* Switch to a different project path and load its data
*/
@@ -443,6 +472,20 @@ export async function loadDashboardData(path: string): Promise<{
return fetchApi(`/api/data?path=${encodeURIComponent(path)}`);
}
/**
* Switch workspace to a different project path
*/
export async function switchWorkspace(path: string): Promise<SwitchWorkspaceResponse> {
return fetchApi<SwitchWorkspaceResponse>(`/api/switch-path?path=${encodeURIComponent(path)}`);
}
/**
* Fetch data for a specific path
*/
export async function fetchDataForPath(path: string): Promise<FetchDataForPathResponse> {
return fetchApi<FetchDataForPathResponse>(`/api/data?path=${encodeURIComponent(path)}`);
}
// ========== Loops API ==========
export interface Loop {
@@ -1001,6 +1044,7 @@ export interface ConversationTurn {
};
timestamp: string;
duration_ms: number;
status?: 'success' | 'error' | 'timeout';
}
// ========== CLI Tools Config API ==========
@@ -1259,6 +1303,39 @@ export async function toggleMcpServer(
});
}
// ========== Codex MCP API ==========
/**
* Codex MCP Server - Read-only server with config path
* Extends McpServer with optional configPath field
*/
export interface CodexMcpServer extends McpServer {
configPath?: string;
}
export interface CodexMcpServersResponse {
servers: CodexMcpServer[];
configPath: string;
}
/**
* Fetch Codex MCP servers from config.toml
* Codex MCP servers are read-only (managed via config file)
*/
export async function fetchCodexMcpServers(): Promise<CodexMcpServersResponse> {
return fetchApi<CodexMcpServersResponse>('/api/mcp/codex-servers');
}
/**
* Add a new MCP server to Codex config
* Note: This requires write access to Codex config.toml
*/
export async function addCodexMcpServer(server: Omit<McpServer, 'name'>): Promise<CodexMcpServer> {
return fetchApi<CodexMcpServer>('/api/mcp/codex-add', {
method: 'POST',
body: JSON.stringify(server),
});
}
// ========== CLI Endpoints API ==========
export interface CliEndpoint {
@@ -1398,7 +1475,9 @@ export interface Hook {
description?: string;
enabled: boolean;
script?: string;
trigger: 'pre-commit' | 'post-commit' | 'pre-push' | 'custom';
command?: string;
trigger: string;
matcher?: string;
}
export interface HooksResponse {
@@ -1441,22 +1520,52 @@ export async function toggleHook(
});
}
/**
* Create a new hook
*/
export async function createHook(
input: { name: string; description?: string; trigger: string; matcher?: string; command: string }
): Promise<Hook> {
return fetchApi<Hook>('/api/hooks/create', {
method: 'POST',
body: JSON.stringify(input),
});
}
/**
* Update hook using dedicated update endpoint with partial input
*/
export async function updateHookConfig(
hookName: string,
input: { description?: string; trigger?: string; matcher?: string; command?: string }
): Promise<Hook> {
return fetchApi<Hook>('/api/hooks/update', {
method: 'POST',
body: JSON.stringify({ name: hookName, ...input }),
});
}
/**
* Delete a hook
*/
export async function deleteHook(hookName: string): Promise<void> {
return fetchApi<void>(`/api/hooks/delete/${encodeURIComponent(hookName)}`, {
method: 'DELETE',
});
}
/**
* Install a hook from predefined template
*/
export async function installHookTemplate(templateId: string): Promise<Hook> {
return fetchApi<Hook>('/api/hooks/install-template', {
method: 'POST',
body: JSON.stringify({ templateId }),
});
}
// ========== Rules API ==========
export interface Rule {
id: string;
name: string;
description?: string;
enabled: boolean;
category?: string;
pattern?: string;
severity?: 'error' | 'warning' | 'info';
}
export interface RulesResponse {
rules: Rule[];
}
/**
* Fetch all rules
*/
@@ -1492,3 +1601,328 @@ export async function toggleRule(
body: JSON.stringify({ enabled }),
});
}
/**
* Create a new rule
*/
export async function createRule(input: RuleCreateInput): Promise<Rule> {
return fetchApi<Rule>('/api/rules/create', {
method: 'POST',
body: JSON.stringify(input),
});
}
/**
* Delete a rule
*/
export async function deleteRule(
ruleId: string,
location?: string
): Promise<void> {
return fetchApi<void>(`/api/rules/${encodeURIComponent(ruleId)}`, {
method: 'DELETE',
body: JSON.stringify({ location }),
});
}
// ========== CCW Tools MCP API ==========
/**
* CCW MCP configuration interface
*/
export interface CcwMcpConfig {
isInstalled: boolean;
enabledTools: string[];
projectRoot?: string;
allowedDirs?: string;
disableSandbox?: boolean;
}
/**
* Fetch CCW Tools MCP configuration
*/
export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
const data = await fetchApi<CcwMcpConfig>('/api/mcp/ccw-config');
return data;
}
/**
* Update CCW Tools MCP configuration
*/
export async function updateCcwConfig(config: {
enabledTools?: string[];
projectRoot?: string;
allowedDirs?: string;
disableSandbox?: boolean;
}): Promise<CcwMcpConfig> {
return fetchApi<CcwMcpConfig>('/api/mcp/ccw-config', {
method: 'PATCH',
body: JSON.stringify(config),
});
}
/**
* Install CCW Tools MCP server
*/
export async function installCcwMcp(): Promise<CcwMcpConfig> {
return fetchApi<CcwMcpConfig>('/api/mcp/ccw-install', {
method: 'POST',
});
}
/**
* Uninstall CCW Tools MCP server
*/
export async function uninstallCcwMcp(): Promise<void> {
await fetchApi<void>('/api/mcp/ccw-uninstall', {
method: 'POST',
});
}
// ========== Index Management API ==========
/**
* Fetch current index status
*/
export async function fetchIndexStatus(): Promise<IndexStatus> {
return fetchApi<IndexStatus>('/api/index/status');
}
/**
* Rebuild index
*/
export async function rebuildIndex(request: IndexRebuildRequest = {}): Promise<IndexStatus> {
return fetchApi<IndexStatus>('/api/index/rebuild', {
method: 'POST',
body: JSON.stringify(request),
});
}
// ========== Prompt History API ==========
/**
* Prompt history response from backend
*/
export interface PromptsResponse {
prompts: Prompt[];
total: number;
}
/**
* Prompt insights response from backend
*/
export interface PromptInsightsResponse {
insights: PromptInsight[];
patterns: Pattern[];
suggestions: Suggestion[];
}
/**
* Analyze prompts request
*/
export interface AnalyzePromptsRequest {
tool?: 'gemini' | 'qwen' | 'codex';
promptIds?: string[];
limit?: number;
}
/**
* Fetch all prompts from history
*/
export async function fetchPrompts(): Promise<PromptsResponse> {
return fetchApi<PromptsResponse>('/api/memory/prompts');
}
/**
* Fetch prompt insights from backend
*/
export async function fetchPromptInsights(): Promise<PromptInsightsResponse> {
return fetchApi<PromptInsightsResponse>('/api/memory/insights');
}
/**
* Analyze prompts using AI tool
*/
export async function analyzePrompts(request: AnalyzePromptsRequest = {}): Promise<PromptInsightsResponse> {
return fetchApi<PromptInsightsResponse>('/api/memory/analyze', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Delete a prompt from history
*/
export async function deletePrompt(promptId: string): Promise<void> {
await fetchApi<void>('/api/memory/prompts/' + encodeURIComponent(promptId), {
method: 'DELETE',
});
}
// ========== File Explorer API ==========
/**
* File tree response from backend
*/
export interface FileTreeResponse {
rootNodes: import('../types/file-explorer').FileSystemNode[];
fileCount: number;
directoryCount: number;
totalSize: number;
buildTime: number;
}
/**
* Fetch file tree for a given root path
*/
export async function fetchFileTree(rootPath: string = '/', options: {
maxDepth?: number;
includeHidden?: boolean;
excludePatterns?: string[];
} = {}): Promise<FileTreeResponse> {
const params = new URLSearchParams();
params.append('rootPath', rootPath);
if (options.maxDepth !== undefined) params.append('maxDepth', String(options.maxDepth));
if (options.includeHidden !== undefined) params.append('includeHidden', String(options.includeHidden));
if (options.excludePatterns) params.append('excludePatterns', options.excludePatterns.join(','));
return fetchApi<FileTreeResponse>(`/api/explorer/tree?${params.toString()}`);
}
/**
* Fetch file content
*/
export async function fetchFileContent(filePath: string, options: {
encoding?: 'utf8' | 'ascii' | 'base64';
maxSize?: number;
} = {}): Promise<import('../types/file-explorer').FileContent> {
const params = new URLSearchParams();
params.append('path', filePath);
if (options.encoding) params.append('encoding', options.encoding);
if (options.maxSize !== undefined) params.append('maxSize', String(options.maxSize));
return fetchApi<import('../types/file-explorer').FileContent>(`/api/explorer/file?${params.toString()}`);
}
/**
* Search files request
*/
export interface SearchFilesRequest {
rootPath?: string;
query: string;
filePatterns?: string[];
excludePatterns?: string[];
maxResults?: number;
caseSensitive?: boolean;
}
/**
* Search files response
*/
export interface SearchFilesResponse {
results: Array<{
path: string;
name: string;
type: 'file' | 'directory';
matches: Array<{
line: number;
column: number;
context: string;
}>;
}>;
totalMatches: number;
searchTime: number;
}
/**
* Search files by content or name
*/
export async function searchFiles(request: SearchFilesRequest): Promise<SearchFilesResponse> {
return fetchApi<SearchFilesResponse>('/api/explorer/search', {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Get available root directories
*/
export interface RootDirectory {
path: string;
name: string;
isWorkspace: boolean;
isGitRoot: boolean;
}
export async function fetchRootDirectories(): Promise<RootDirectory[]> {
return fetchApi<RootDirectory[]>('/api/explorer/roots');
}
// ========== Graph Explorer API ==========
/**
* Graph dependencies request
*/
export interface GraphDependenciesRequest {
rootPath?: string;
maxDepth?: number;
includeTypes?: string[];
excludePatterns?: string[];
}
/**
* Graph dependencies response
*/
export interface GraphDependenciesResponse {
nodes: import('../types/graph-explorer').GraphNode[];
edges: import('../types/graph-explorer').GraphEdge[];
metadata: import('../types/graph-explorer').GraphMetadata;
}
/**
* Fetch graph dependencies for code visualization
*/
export async function fetchGraphDependencies(request: GraphDependenciesRequest = {}): Promise<GraphDependenciesResponse> {
const params = new URLSearchParams();
if (request.rootPath) params.append('rootPath', request.rootPath);
if (request.maxDepth !== undefined) params.append('maxDepth', String(request.maxDepth));
if (request.includeTypes) params.append('includeTypes', request.includeTypes.join(','));
if (request.excludePatterns) params.append('excludePatterns', request.excludePatterns.join(','));
return fetchApi<GraphDependenciesResponse>(`/api/graph/dependencies?${params.toString()}`);
}
/**
* Graph impact analysis request
*/
export interface GraphImpactRequest {
nodeId: string;
direction?: 'upstream' | 'downstream' | 'both';
maxDepth?: number;
}
/**
* Graph impact analysis response
*/
export interface GraphImpactResponse {
nodeId: string;
dependencies: import('../types/graph-explorer').GraphNode[];
dependents: import('../types/graph-explorer').GraphNode[];
paths: Array<{
nodes: string[];
edges: string[];
}>;
}
/**
* Fetch impact analysis for a specific node
*/
export async function fetchGraphImpact(request: GraphImpactRequest): Promise<GraphImpactResponse> {
const params = new URLSearchParams();
params.append('nodeId', request.nodeId);
if (request.direction) params.append('direction', request.direction);
if (request.maxDepth !== undefined) params.append('maxDepth', String(request.maxDepth));
return fetchApi<GraphImpactResponse>(`/api/graph/impact?${params.toString()}`);
}

View File

@@ -0,0 +1,95 @@
// ========================================
// Workspace-Aware Query Keys Factory
// ========================================
// TanStack Query key factory with projectPath prefix for cache isolation
/**
* Workspace-aware query keys factory
* All keys include projectPath for cache isolation between workspaces
*/
export const workspaceQueryKeys = {
// Base key that includes projectPath
all: (projectPath: string) => ['workspace', projectPath] as const,
// ========== Sessions ==========
sessions: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'sessions'] as const,
sessionsList: (projectPath: string) => [...workspaceQueryKeys.sessions(projectPath), 'list'] as const,
sessionDetail: (projectPath: string, sessionId: string) =>
[...workspaceQueryKeys.sessions(projectPath), 'detail', sessionId] as const,
// ========== Tasks ==========
tasks: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'tasks'] as const,
tasksList: (projectPath: string, sessionId: string) =>
[...workspaceQueryKeys.tasks(projectPath), 'list', sessionId] as const,
taskDetail: (projectPath: string, taskId: string) =>
[...workspaceQueryKeys.tasks(projectPath), 'detail', taskId] as const,
// ========== Loops ==========
loops: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'loops'] as const,
loopsList: (projectPath: string) => [...workspaceQueryKeys.loops(projectPath), 'list'] as const,
loopDetail: (projectPath: string, loopId: string) =>
[...workspaceQueryKeys.loops(projectPath), 'detail', loopId] as const,
// ========== Issues ==========
issues: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'issues'] as const,
issuesList: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'list'] as const,
issuesHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'history'] as const,
issueQueue: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queue'] as const,
// ========== Memory ==========
memory: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'memory'] as const,
memoryList: (projectPath: string) => [...workspaceQueryKeys.memory(projectPath), 'list'] as const,
memoryDetail: (projectPath: string, memoryId: string) =>
[...workspaceQueryKeys.memory(projectPath), 'detail', memoryId] as const,
// ========== Project Overview ==========
projectOverview: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'projectOverview'] as const,
projectOverviewDetail: (projectPath: string) =>
[...workspaceQueryKeys.projectOverview(projectPath), 'detail'] as const,
// ========== Lite Tasks ==========
liteTasks: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'liteTasks'] as const,
liteTasksList: (projectPath: string, type?: 'lite-plan' | 'lite-fix' | 'multi-cli-plan') =>
[...workspaceQueryKeys.liteTasks(projectPath), 'list', type] as const,
liteTaskDetail: (projectPath: string, sessionId: string) =>
[...workspaceQueryKeys.liteTasks(projectPath), 'detail', sessionId] as const,
// ========== Review Sessions ==========
reviewSessions: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'reviewSessions'] as const,
reviewSessionsList: (projectPath: string) => [...workspaceQueryKeys.reviewSessions(projectPath), 'list'] as const,
reviewSessionDetail: (projectPath: string, sessionId: string) =>
[...workspaceQueryKeys.reviewSessions(projectPath), 'detail', sessionId] as const,
// ========== Rules ==========
rules: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'rules'] as const,
rulesList: (projectPath: string) => [...workspaceQueryKeys.rules(projectPath), 'list'] as const,
// ========== Prompts ==========
prompts: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'prompts'] as const,
promptsList: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'list'] as const,
promptsInsights: (projectPath: string) => [...workspaceQueryKeys.prompts(projectPath), 'insights'] as const,
// ========== Index ==========
index: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'index'] as const,
indexStatus: (projectPath: string) => [...workspaceQueryKeys.index(projectPath), 'status'] as const,
// ========== File Explorer ==========
explorer: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'explorer'] as const,
explorerTree: (projectPath: string, rootPath?: string) =>
[...workspaceQueryKeys.explorer(projectPath), 'tree', rootPath] as const,
explorerFile: (projectPath: string, filePath?: string) =>
[...workspaceQueryKeys.explorer(projectPath), 'file', filePath] as const,
// ========== Graph Explorer ==========
graph: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'graph'] as const,
graphDependencies: (projectPath: string, options?: { maxDepth?: number }) =>
[...workspaceQueryKeys.graph(projectPath), 'dependencies', options] as const,
graphImpact: (projectPath: string, nodeId: string) =>
[...workspaceQueryKeys.graph(projectPath), 'impact', nodeId] as const,
// ========== CLI History ==========
cliHistory: (projectPath: string) => [...workspaceQueryKeys.all(projectPath), 'cliHistory'] as const,
cliHistoryList: (projectPath: string) => [...workspaceQueryKeys.cliHistory(projectPath), 'list'] as const,
cliExecutionDetail: (projectPath: string, executionId: string) =>
[...workspaceQueryKeys.cliHistory(projectPath), 'detail', executionId] as const,
};