From 433f601122f6fac24630e1c6d5e9df7af213c3b5 Mon Sep 17 00:00:00 2001 From: weishao Date: Sat, 22 Aug 2026 17:24:08 +0800 Subject: [PATCH 1/5] refactor(web-ui): route business invokes through adapter layer and drop dead AgentService - Add eslint no-restricted-imports fence: business code must reach the platform only via api.invoke (ApiClient); direct invoke from '@tauri-apps/api/core' is reserved for adapters/** and the intentional PeerHostInvokeBridge exception. - Reroute 8 A-class modules (insights, i18n, companion pet, announcement, file/image context, ide-control event bus) from direct invoke to api.invoke. - Delete the dead legacy agent-service.ts wrapper (no consumers) and its FlowChatManager field/import/initialization, the orphaned getAvailableAgents() method, and its test mock. Co-Authored-By: Claude --- src/web-ui/eslint.config.mjs | 35 + .../services/FlowChatManager.test.ts | 6 - .../src/flow_chat/services/FlowChatManager.ts | 9 +- .../src/infrastructure/api/insightsApi.ts | 12 +- .../infrastructure/api/service-api/I18nAPI.ts | 12 +- .../services/AgentCompanionPetService.ts | 8 +- .../services/AnnouncementService.ts | 14 +- .../core/types/FileContextImpl.tsx | 12 +- .../core/types/ImageContextImpl.tsx | 4 +- .../src/shared/services/agent-service.ts | 632 ------------------ .../ide-control/IdeControlEventBus.ts | 4 +- 11 files changed, 69 insertions(+), 679 deletions(-) delete mode 100644 src/web-ui/src/shared/services/agent-service.ts diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 426751c7cf..7b3eaa03fc 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -22,6 +22,41 @@ export default tseslint.config( 'src/shared/context-menu-system/examples/**', ], }, + { + // Adapter-layer fence: business Tauri commands must reach the platform + // only through ApiClient (api.invoke). Direct `invoke` from + // '@tauri-apps/api/core' is reserved for the adapter implementations in + // adapters/** (and the peer-device host bridge, which intentionally runs + // outside the routed transport — see PeerHostInvokeBridge). This is the + // executable form of "front end calls go through the adapter layer"; + // reintroducing a direct invoke elsewhere fails the build. + files: ['src/**/*.{ts,tsx}'], + ignores: [ + 'src/infrastructure/api/adapters/**', + // PeerHostInvokeBridge runs on the HOST side of Peer-Device Mode: it + // executes *dynamic* command names forwarded from the peer device via a + // raw Tauri invoke. ApiClient is already routed to the peer adapter at + // this point, so routing through it would be wrong. This is the one + // intentional exception to the adapter-layer fence. + 'src/infrastructure/peer-device/PeerHostInvokeBridge.tsx', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@tauri-apps/api/core'], + importNames: ['invoke'], + message: + '业务命令必须经 api.invoke(ApiClient) 统一适配层,不可直接 import invoke。' + + '如需直连平台 invoke,放到 adapters/ 内并经 api 暴露。', + }, + ], + }, + ], + }, + }, { files: ['src/**/*.{ts,tsx}'], extends: [js.configs.recommended, ...tseslint.configs.recommended], diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts index d5e9130beb..daefcf8899 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts @@ -42,12 +42,6 @@ vi.mock('../store/FlowChatStore', () => ({ }, })); -vi.mock('../../shared/services/agent-service', () => ({ - AgentService: { - getInstance: vi.fn(() => ({})), - }, -})); - vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ ACPClientAPI: {}, })); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 690ebd6f36..3c9c8a00b6 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -10,7 +10,6 @@ import { processingStatusManager } from './ProcessingStatusManager'; import { FlowChatStore } from '../store/FlowChatStore'; import { useModernFlowChatStore } from '../store/modernFlowChatStore'; -import { AgentService } from '../../shared/services/agent-service'; import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { stateMachineManager } from '../state-machine'; import { EventBatcher } from './EventBatcher'; @@ -79,7 +78,6 @@ const EVENT_LISTENER_RETRY_MS = 2000; export class FlowChatManager { private static instance: FlowChatManager | null = null; private context: FlowChatContext; - private agentService: AgentService; private eventListenerInitialized = false; private eventListenerInitializationPromise: Promise | null = null; private eventListenerCleanup: (() => void) | null = null; @@ -117,8 +115,7 @@ export class FlowChatManager { currentWorkspacePath: null, ensureLiveSubscription: () => this.ensureEventListeners(), }; - - this.agentService = AgentService.getInstance(); + registerDriverSessionLookup( sessionId => this.context.flowChatStore.getState().sessions.get(sessionId), ); @@ -917,10 +914,6 @@ export class FlowChatManager { updateImageAnalysisItemModule(this.context, sessionId, dialogTurnId, imageId, updates); } - async getAvailableAgents(): Promise { - return this.agentService.getAvailableAgents(); - } - getCurrentSession() { return this.context.flowChatStore.getActiveSession(); } diff --git a/src/web-ui/src/infrastructure/api/insightsApi.ts b/src/web-ui/src/infrastructure/api/insightsApi.ts index 95cf679da3..05c5c377f7 100644 --- a/src/web-ui/src/infrastructure/api/insightsApi.ts +++ b/src/web-ui/src/infrastructure/api/insightsApi.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { api } from './service-api/ApiClient'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { openPath } from '@tauri-apps/plugin-opener'; @@ -176,29 +176,29 @@ export interface InsightsProgressEvent { export const insightsApi = { async generateInsights(days?: number, modelId?: string): Promise { - return invoke('generate_insights', { + return api.invoke('generate_insights', { request: { days: days ?? 30, modelId: modelId || 'auto' }, }); }, async getLatestInsights(): Promise { - return invoke('get_latest_insights'); + return api.invoke('get_latest_insights'); }, async loadReport(path: string): Promise { - return invoke('load_insights_report', { + return api.invoke('load_insights_report', { request: { path }, }); }, async hasInsightsData(days?: number): Promise { - return invoke('has_insights_data', { + return api.invoke('has_insights_data', { request: { days: days ?? 30 }, }); }, async cancelGeneration(): Promise { - return invoke('cancel_insights_generation'); + return api.invoke('cancel_insights_generation'); }, async listenProgress( diff --git a/src/web-ui/src/infrastructure/api/service-api/I18nAPI.ts b/src/web-ui/src/infrastructure/api/service-api/I18nAPI.ts index b1df6adcf0..270208943e 100644 --- a/src/web-ui/src/infrastructure/api/service-api/I18nAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/I18nAPI.ts @@ -1,6 +1,6 @@ -import { invoke } from '@tauri-apps/api/core'; +import { api } from './ApiClient'; import type { LocaleId, LocaleMetadata, I18nConfig } from '@/infrastructure/i18n/types'; import { getLocaleMetadata } from '@/infrastructure/i18n/presets'; import { createLogger } from '@/shared/utils/logger'; @@ -21,7 +21,7 @@ class I18nAPIClass { async getCurrentLanguage(): Promise { try { - const language = await invoke('i18n_get_current_language'); + const language = await api.invoke('i18n_get_current_language'); return language as LocaleId; } catch (error) { log.warn('Failed to get current language, using default', error); @@ -31,14 +31,14 @@ class I18nAPIClass { async setLanguage(language: LocaleId): Promise { - return invoke('i18n_set_language', { + return api.invoke('i18n_set_language', { request: { language } }); } async getSupportedLanguages(): Promise { - const response = await invoke('i18n_get_supported_languages'); + const response = await api.invoke('i18n_get_supported_languages'); return response.map(item => { const id = item.id as LocaleId; @@ -67,7 +67,7 @@ class I18nAPIClass { async getConfig(): Promise { try { - const config = await invoke('i18n_get_config'); + const config = await api.invoke('i18n_get_config'); return { currentLanguage: config.currentLanguage || 'zh-CN', fallbackLanguage: config.fallbackLanguage || 'en-US', @@ -87,7 +87,7 @@ class I18nAPIClass { async setConfig(config: Partial): Promise { - return invoke('i18n_set_config', { config }); + return api.invoke('i18n_set_config', { config }); } } diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts index e91e114947..667705752e 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { readFile } from '@tauri-apps/plugin-fs'; import type { AgentCompanionPetSelection } from './AIExperienceConfigService'; import { isTauriRuntime } from '@/infrastructure/runtime'; @@ -172,7 +172,7 @@ export async function listAgentCompanionPets(): Promise('list_agent_companion_pets'); + const response = await api.invoke('list_agent_companion_pets'); const userPets = await Promise.all(response.pets.map(withPreviewSrc)); return [...builtinPets, ...userPets]; } catch (error) { @@ -182,14 +182,14 @@ export async function listAgentCompanionPets(): Promise { - const pet = await invoke('import_agent_companion_pet_package', { + const pet = await api.invoke('import_agent_companion_pet_package', { request: { path }, }); return withPreviewSrc(pet); } export async function deleteAgentCompanionPetPackage(packagePath: string): Promise { - await invoke('delete_agent_companion_pet_package', { + await api.invoke('delete_agent_companion_pet_package', { request: { packagePath }, }); } diff --git a/src/web-ui/src/shared/announcement-system/services/AnnouncementService.ts b/src/web-ui/src/shared/announcement-system/services/AnnouncementService.ts index 4ca4a266c3..08e61b5a55 100644 --- a/src/web-ui/src/shared/announcement-system/services/AnnouncementService.ts +++ b/src/web-ui/src/shared/announcement-system/services/AnnouncementService.ts @@ -4,7 +4,7 @@ * Wraps all Tauri `invoke` calls for the announcement system so that the * rest of the frontend never touches `invoke` directly. */ -import { invoke } from '@tauri-apps/api/core'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { AnnouncementCard } from '../types'; import { createLogger } from '@/shared/utils/logger'; @@ -18,7 +18,7 @@ export const announcementService = { */ async getPendingAnnouncements(): Promise { try { - return await invoke('get_pending_announcements'); + return await api.invoke('get_pending_announcements'); } catch (e) { log.error('Failed to get pending announcements', e); return []; @@ -28,7 +28,7 @@ export const announcementService = { /** Mark a card as seen (modal was opened or action button was clicked). */ async markSeen(id: string): Promise { try { - await invoke('mark_announcement_seen', { request: { id } }); + await api.invoke('mark_announcement_seen', { request: { id } }); } catch (e) { log.error('Failed to mark announcement seen', { id, error: e }); } @@ -37,7 +37,7 @@ export const announcementService = { /** Dismiss a card for the current version cycle. */ async dismiss(id: string): Promise { try { - await invoke('dismiss_announcement', { request: { id } }); + await api.invoke('dismiss_announcement', { request: { id } }); } catch (e) { log.error('Failed to dismiss announcement', { id, error: e }); } @@ -46,7 +46,7 @@ export const announcementService = { /** Permanently suppress a card. */ async neverShow(id: string): Promise { try { - await invoke('never_show_announcement', { request: { id } }); + await api.invoke('never_show_announcement', { request: { id } }); } catch (e) { log.error('Failed to suppress announcement', { id, error: e }); } @@ -58,7 +58,7 @@ export const announcementService = { */ async triggerCard(id: string): Promise { try { - return await invoke('trigger_announcement', { request: { id } }); + return await api.invoke('trigger_announcement', { request: { id } }); } catch (e) { log.error('Failed to trigger announcement', { id, error: e }); return null; @@ -68,7 +68,7 @@ export const announcementService = { /** Fetch all currently eligible tip cards (for a tips browser). */ async getTips(): Promise { try { - return await invoke('get_announcement_tips'); + return await api.invoke('get_announcement_tips'); } catch (e) { log.error('Failed to get announcement tips', e); return []; diff --git a/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx b/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx index 939b96e58e..e1817b5e83 100644 --- a/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx +++ b/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { FileIcon, CheckCircle } from 'lucide-react'; import type { FileContext, ValidationResult, RenderOptions } from '../../../types/context'; -import type { - ContextTransformer, - ContextValidator, - ContextCardRenderer +import type { + ContextTransformer, + ContextValidator, + ContextCardRenderer } from '../../../services/ContextRegistry'; import { i18nService } from '@/infrastructure/i18n'; @@ -42,7 +42,7 @@ export class FileContextValidator implements ContextValidator<'file'> { async validate(context: FileContext): Promise { try { - const exists = await invoke('fs_exists', { path: context.filePath }); + const exists = await api.invoke('fs_exists', { path: context.filePath }); if (!exists) { return { diff --git a/src/web-ui/src/shared/context-system/core/types/ImageContextImpl.tsx b/src/web-ui/src/shared/context-system/core/types/ImageContextImpl.tsx index d48dff6b9e..ad8a5f2b50 100644 --- a/src/web-ui/src/shared/context-system/core/types/ImageContextImpl.tsx +++ b/src/web-ui/src/shared/context-system/core/types/ImageContextImpl.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { Image as ImageIcon, Eye } from 'lucide-react'; import { Modal, Button } from '@/component-library'; import type { ImageContext, ValidationResult, RenderOptions } from '../../../types/context'; @@ -86,7 +86,7 @@ export class ImageContextValidator implements ContextValidator<'image'> { if (context.isLocal && context.imagePath) { try { - const exists = await invoke('check_path_exists', { + const exists = await api.invoke('check_path_exists', { request: { path: context.imagePath } diff --git a/src/web-ui/src/shared/services/agent-service.ts b/src/web-ui/src/shared/services/agent-service.ts deleted file mode 100644 index 6930ae80b4..0000000000 --- a/src/web-ui/src/shared/services/agent-service.ts +++ /dev/null @@ -1,632 +0,0 @@ -/** - * Agent service (frontend). - * - * Wraps agent/tool APIs and bridges backend streaming events into a convenient - * client-side interface. - */ -import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; -import { toolAPI } from '@/infrastructure/api/service-api/ToolAPI'; -import { listen } from '@tauri-apps/api/event'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('AgentService'); -const hasTauriRuntime = (): boolean => - typeof window !== 'undefined' && - ('__TAURI_INTERNALS__' in window || '__TAURI__' in window); -import type { - AgentExecutionRequest, - AgentExecutionResponse, - AgentInfo, - ToolInfo, - ToolExecutionRequest, - ToolExecutionResponse, - ToolValidationRequest, - ToolValidationResponse, - AgentTaskUpdateEvent, - StreamChunkEvent, - StreamToolUseEvent, - StreamToolResultEvent, - StreamProgressEvent, - StreamStartEvent, - StreamCompleteEvent, - StreamErrorEvent, - ToolCallConfirmationEvent, -} from '../types/agent-api'; - -export class AgentService { - private static instance: AgentService; - private taskListeners = new Map void>(); - private streamListeners = new Map void; - onToolUse?: (event: StreamToolUseEvent) => void; - onToolResult?: (event: StreamToolResultEvent) => void; - onProgress?: (event: StreamProgressEvent) => void; - onComplete?: (event: StreamCompleteEvent) => void; - onError?: (event: StreamErrorEvent) => void; - onModelRoundStart?: (event: any) => void; - onToolConfirmation?: (event: ToolCallConfirmationEvent) => void; - }>(); - private unlistenFunctions: Array<() => void> = []; - - private constructor() { - void this.setupEventListeners().catch(error => { - log.warn('Failed to setup event listeners during startup', error); - }); - - - if (import.meta.hot) { - import.meta.hot.dispose(() => { - this.cleanup(); - }); - } - } - - static getInstance(): AgentService { - if (!AgentService.instance) { - AgentService.instance = new AgentService(); - } - return AgentService.instance; - } - - - private cleanup(): void { - this.unlistenFunctions.forEach(unlisten => { - try { - unlisten(); - } catch (e) { - log.warn('Failed to cleanup listener', e); - } - }); - this.unlistenFunctions = []; - this.taskListeners.clear(); - this.streamListeners.clear(); - } - - private async setupEventListeners() { - if (!hasTauriRuntime()) { - log.warn('Tauri runtime not available, skipping agent event listeners'); - return; - } - - const unlisten1 = await listen('agent_task_update', (event) => { - const taskEvent = event.payload; - const listener = this.taskListeners.get(taskEvent.task_id); - if (listener) { - listener(taskEvent); - } - }); - this.unlistenFunctions.push(unlisten1); - - - const unlisten2 = await listen('agentic_stream_start', () => { - // Stream started event handled - }); - this.unlistenFunctions.push(unlisten2); - - - const unlisten3 = await listen('model_round_start', (event) => { - const startEvent = event.payload; - - if (startEvent.task_id) { - const listener = this.streamListeners.get(startEvent.task_id); - if (listener && listener.onModelRoundStart) { - listener.onModelRoundStart(startEvent); - } - } - }); - this.unlistenFunctions.push(unlisten3); - - - const unlisten4 = await listen('tool_execution_event', (event) => { - const toolEvent = event.payload; - - - if (toolEvent.tool_name === 'TodoWrite' && toolEvent.type === 'tool_start') { - - Promise.all([ - import('@/flow_chat/services/FlowChatManager'), - import('@/flow_chat/state-machine') - ]).then(([{ FlowChatManager }, { stateMachineManager }]) => { - const todos = toolEvent.input?.todos || []; - const merge = toolEvent.input?.merge || false; - - - const flowChatManager = FlowChatManager.getInstance(); - const sessionId = flowChatManager.getSessionIdByTaskId(toolEvent.task_id); - - if (sessionId) { - const machine = stateMachineManager.get(sessionId); - if (machine) { - const context = machine.getContext(); - - - if (merge && context.planner) { - - const existingTodos = context.planner.todos; - const todoMap = new Map(existingTodos.map(t => [t.id, t])); - todos.forEach((todo: any) => { - todoMap.set(todo.id, todo); - }); - context.planner.todos = Array.from(todoMap.values()); - } else { - - context.planner = { - todos, - isActive: true - }; - } - } - } - }).catch(err => { - log.error('Failed to update state machine Planner', err); - }); - } - - if (toolEvent.task_id) { - const listener = this.streamListeners.get(toolEvent.task_id); - - if (listener) { - - if (toolEvent.type === 'tool_preparing' && listener.onToolUse) { - listener.onToolUse({ - task_id: toolEvent.task_id, - tool_use_id: toolEvent.tool_use_id, - tool_name: toolEvent.tool_name, - input: { _early_detection: true }, - model_round_id: toolEvent.model_round_id, - dialog_turn_id: toolEvent.dialog_turn_id, - timestamp: toolEvent.timestamp || Date.now(), - ai_intent: undefined, - requires_confirmation: false, - _is_early_detection: true - } as any); - } else if (toolEvent.type === 'tool_start' && listener.onToolUse) { - listener.onToolUse({ - task_id: toolEvent.task_id, - tool_use_id: toolEvent.tool_use_id, - tool_name: toolEvent.tool_name, - input: toolEvent.input, - model_round_id: toolEvent.model_round_id, - dialog_turn_id: toolEvent.dialog_turn_id, - timestamp: toolEvent.timestamp || Date.now(), - ai_intent: toolEvent.ai_intent, - requires_confirmation: toolEvent.requires_confirmation - } as any); - } else if (toolEvent.type === 'tool_complete' && listener.onToolResult) { - const resultEvent = { - task_id: toolEvent.task_id, - type: 'tool_result' as const, - content: toolEvent.result?.content || '', - timestamp: toolEvent.timestamp || Date.now(), - tool: toolEvent.tool_name, - tool_name: toolEvent.tool_name, - tool_use_id: toolEvent.tool_use_id, - result: { - content: toolEvent.result?.content || '', - data: toolEvent.result?.data, - type: toolEvent.success ? 'result' : 'error', - success: toolEvent.success, - error: toolEvent.error, - duration_ms: toolEvent.duration_ms - } - }; - - listener.onToolResult(resultEvent as any); - } - } - - } - }); - this.unlistenFunctions.push(unlisten4); - - - const unlisten5 = await listen('model_round_content', (event) => { - const contentEvent = event.payload; - - if (contentEvent.task_id) { - const listener = this.streamListeners.get(contentEvent.task_id); - - if (listener) { - - if (contentEvent.content_type === 'text' && contentEvent.content && listener.onChunk) { - listener.onChunk({ - task_id: contentEvent.task_id, - type: 'text' as const, - content: contentEvent.content, - model_round_id: contentEvent.model_round_id, - dialog_turn_id: contentEvent.dialog_turn_id, - timestamp: Date.now() - }); - } else if (contentEvent.content_type === 'thinking' && contentEvent.content && listener.onChunk) { - - listener.onChunk({ - task_id: contentEvent.task_id, - type: 'thinking' as const, - content: contentEvent.content, - model_round_id: contentEvent.model_round_id, - dialog_turn_id: contentEvent.dialog_turn_id, - timestamp: Date.now() - }); - } - } - - } - }); - this.unlistenFunctions.push(unlisten5); - - const unlisten6 = await listen('agentic_stream_chunk', (event) => { - const chunkEvent = event.payload; - const listener = this.streamListeners.get(chunkEvent.task_id); - if (listener?.onChunk) { - listener.onChunk(chunkEvent); - } - }); - this.unlistenFunctions.push(unlisten6); - - const unlisten7 = await listen('agentic_stream_tool_use', (event) => { - const toolUseEvent = event.payload; - const listener = this.streamListeners.get(toolUseEvent.task_id); - if (listener?.onToolUse) { - - listener.onToolUse(toolUseEvent); - } - }); - this.unlistenFunctions.push(unlisten7); - - const unlisten8 = await listen('agentic_stream_tool_result', (event) => { - const toolResultEvent = event.payload; - const listener = this.streamListeners.get(toolResultEvent.task_id); - if (listener?.onToolResult) { - listener.onToolResult(toolResultEvent); - } - }); - this.unlistenFunctions.push(unlisten8); - - const unlisten9 = await listen('agentic_stream_progress', (event) => { - const progressEvent = event.payload; - const listener = this.streamListeners.get(progressEvent.task_id); - if (listener?.onProgress) { - listener.onProgress(progressEvent); - } - }); - this.unlistenFunctions.push(unlisten9); - - const unlisten10 = await listen('agentic_stream_complete', (event) => { - const completeEvent = event.payload; - - - const listener = this.streamListeners.get(completeEvent.task_id); - if (listener?.onComplete) { - listener.onComplete(completeEvent); - } - - - - - - this.streamListeners.delete(completeEvent.task_id); - }); - this.unlistenFunctions.push(unlisten10); - - const unlisten11 = await listen('agentic_stream_error', (event) => { - const errorEvent = event.payload; - const listener = this.streamListeners.get(errorEvent.task_id); - if (listener?.onError) { - listener.onError(errorEvent); - } - - this.streamListeners.delete(errorEvent.task_id); - }); - this.unlistenFunctions.push(unlisten11); - - - const unlisten12 = await listen('backend-event-toolcallconfirmation', (event) => { - const confirmationEvent = event.payload; - - - - for (const listener of this.streamListeners.values()) { - if (listener?.onToolConfirmation) { - listener.onToolConfirmation(confirmationEvent); - break; - } - } - }); - this.unlistenFunctions.push(unlisten12); - } - - - - - async getAvailableAgents(): Promise { - - return ['general-purpose']; - } - - - async getActiveAgentConfigs(): Promise { - const agentTypes = await agentAPI.getAvailableTools(); - - return agentTypes.map(type => ({ - id: type, - name: type, - type: type, - description: `${type} agent`, - version: '1.0.0', - status: 'active' as const, - agent_type: type, - when_to_use: `Use ${type} agent for specialized tasks`, - tools: 'all', - location: 'builtin' - })); - } - - - async getAgentInfo(agentType: string): Promise { - return agentAPI.getAgentInfo(agentType); - } - - - async startAgentTaskStream( - request: AgentExecutionRequest, - onUpdate: (event: AgentTaskUpdateEvent) => void - ): Promise { - - const taskId = await this.executeAgentTaskStream(request, {}); - - - this.taskListeners.set(taskId, onUpdate); - - return taskId; - } - - - async executeAgentTaskStream( - request: AgentExecutionRequest, - callbacks: { - onChunk?: (event: StreamChunkEvent) => void; - onToolUse?: (event: StreamToolUseEvent) => void; - onToolResult?: (event: StreamToolResultEvent) => void; - onProgress?: (event: StreamProgressEvent) => void; - onComplete?: (event: StreamCompleteEvent) => void; - onError?: (event: StreamErrorEvent) => void; - onModelRoundStart?: (event: any) => void; - onToolConfirmation?: (event: ToolCallConfirmationEvent) => void; - } - ): Promise { - - - let sessionId: string; - try { - const workspacePath = request.workspace_path; - if (!workspacePath) { - throw new Error('Workspace path is required to create an agent task session'); - } - - const response = await agentAPI.createSession({ - sessionName: `task-${Date.now()}`, - agentType: request.agent_type, - workspacePath, - config: { - modelName: request.model_name, - enableTools: true, - safeMode: true, - } - }); - sessionId = response.sessionId; - } catch (error) { - log.error('Failed to create session', error); - throw error; - } - - - const existingListener = this.streamListeners.get(sessionId); - if (existingListener) { - log.warn('Session ID already has listener, will override', { sessionId }); - } - - - this.streamListeners.set(sessionId, callbacks); - - - try { - const workspacePath = request.workspace_path; - if (!workspacePath) { - throw new Error('Workspace path is required to start an agent task'); - } - - await agentAPI.startDialogTurn({ - sessionId, - userInput: request.prompt, - agentType: request.agent_type, - workspacePath, - }); - } catch (error) { - log.error('Failed to send message', error); - throw error; - } - - return sessionId; - } - - - async cancelAgentTask(taskId: string): Promise { - - await agentAPI.cancelSession(taskId); - const result = true; - - - this.taskListeners.delete(taskId); - - return result; - } - - - cleanupTaskListener(taskId: string) { - this.taskListeners.delete(taskId); - } - - - - - async getAllToolsInfo(): Promise { - return toolAPI.getAllToolsInfo(); - } - - - async getReadonlyToolsInfo(): Promise { - - const allTools = await toolAPI.getAllToolsInfo(); - return allTools.filter((tool: any) => tool.is_readonly === true); - } - - - async getToolInfo(toolName: string): Promise { - return toolAPI.getToolInfo(toolName); - } - - - async validateToolInput(request: ToolValidationRequest): Promise { - - const validationRequest = { - toolName: (request as any).tool_name || (request as any).toolName, - input: request.input || (request as any).parameters, - workspacePath: (request as any).workspace_path || (request as any).workspacePath, - }; - return toolAPI.validateToolInput(validationRequest); - } - - - async executeTool(request: ToolExecutionRequest): Promise { - - const executeRequest = { - toolName: (request as any).tool_name || (request as any).toolName, - parameters: request.input || {}, - workspacePath: (request as any).workspace_path || (request as any).workspacePath, - }; - return toolAPI.executeTool(executeRequest); - } - - - async executeTask( - description: string, - prompt: string, - agentType: string = 'general-purpose', - options: { - modelName?: string; - workspacePath?: string; - context?: Record; - safeMode?: boolean; - verbose?: boolean; - } = {} - ): Promise { - const request: AgentExecutionRequest = { - agent_type: agentType, - prompt, - description, - model_name: options.modelName, - workspace_path: options.workspacePath, - context: options.context, - safe_mode: options.safeMode, - verbose: options.verbose, - }; - - return this.executeAgentTask(request); - } - - async executeAgentTask(request: AgentExecutionRequest): Promise { - const sessionId = await this.executeAgentTaskStream(request, {}); - return { - id: sessionId, - status: 'started', - agent_type: request.agent_type, - }; - } - - - async executeTaskStream( - description: string, - prompt: string, - onUpdate: (event: AgentTaskUpdateEvent) => void, - agentType: string = 'general-purpose', - options: { - modelName?: string; - workspacePath?: string; - context?: Record; - safeMode?: boolean; - verbose?: boolean; - } = {} - ): Promise { - const request: AgentExecutionRequest = { - agent_type: agentType, - prompt, - description, - model_name: options.modelName, - workspace_path: options.workspacePath, - context: options.context, - safe_mode: options.safeMode, - verbose: options.verbose, - }; - - return this.startAgentTaskStream(request, onUpdate); - } - - - async executeTaskStreamNew( - description: string, - prompt: string, - callbacks: { - onChunk?: (text: string) => void; - onToolUse?: (toolName: string, input: any) => void; - onToolResult?: (content: string) => void; - onProgress?: () => void; - onComplete?: (result?: any) => void; - onError?: (error: string) => void; - }, - agentType: string = 'general-purpose', - options: { - modelName?: string; - workspacePath?: string; - context?: Record; - safeMode?: boolean; - verbose?: boolean; - } = {} - ): Promise { - const request: AgentExecutionRequest = { - agent_type: agentType, - prompt, - description, - model_name: options.modelName, - workspace_path: options.workspacePath, - context: options.context, - safe_mode: options.safeMode, - verbose: options.verbose, - }; - - return this.executeAgentTaskStream(request, { - onChunk: callbacks.onChunk ? (event) => callbacks.onChunk!(event.content) : undefined, - onToolUse: callbacks.onToolUse ? (event) => callbacks.onToolUse!(event.tool_name, event.input) : undefined, - onToolResult: callbacks.onToolResult ? (event) => callbacks.onToolResult!(event.content) : undefined, - onProgress: callbacks.onProgress, - onComplete: callbacks.onComplete ? (event) => callbacks.onComplete!(event.result) : undefined, - onError: callbacks.onError ? (event) => callbacks.onError!(event.error) : undefined, - }); - } - - - async isAgentAvailable(agentType: string): Promise { - const availableAgents = await this.getAvailableAgents(); - return availableAgents.includes(agentType); - } - - - async getRecommendedAgent(_taskDescription: string): Promise { - - return 'general-purpose'; - } - - -} - - -export const agentService = AgentService.getInstance(); diff --git a/src/web-ui/src/shared/services/ide-control/IdeControlEventBus.ts b/src/web-ui/src/shared/services/ide-control/IdeControlEventBus.ts index 19807ce15f..c2dcdbe149 100644 --- a/src/web-ui/src/shared/services/ide-control/IdeControlEventBus.ts +++ b/src/web-ui/src/shared/services/ide-control/IdeControlEventBus.ts @@ -4,6 +4,7 @@ * Listens to backend IDE control events and dispatches them to registered controllers. */ import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { IdeControlEvent, IdeController, IdeControlOperation } from './types'; import { PanelController } from './PanelController'; import { createLogger } from '@/shared/utils/logger'; @@ -108,8 +109,7 @@ export class IdeControlEventBus { private async sendErrorResult(requestId: string, error: any): Promise { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('report_ide_control_result', { + await api.invoke('report_ide_control_result', { request_id: requestId, success: false, message: undefined, From f5e5d1281117c1038343b3b92fc022b39b2eb99a Mon Sep 17 00:00:00 2001 From: weishao Date: Mon, 24 Aug 2026 16:21:21 +0800 Subject: [PATCH 2/5] chore(web-ui): delete confirmed zero-consumer dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 11 files and prune the api barrel of entries with no real consumers anywhere in src (verified by full-tree grep + dynamic import + bitfunAPI. access). Follow-up to 433f60112 which dropped the legacy agent-service.ts. Deleted (11 files): - infrastructure/services/ dead chain (5): business/agentService.ts, infra/contextManager.ts, infra/index.ts, api/index.ts (broken barrel exporting non-existent contextService), index.ts (barrel with no importer) - infrastructure/api/service-api/ProjectAPI.ts + GitRepoHistoryAPI.ts (only referenced inside the dead bitfunAPI collection) - shared/crypto/ (e2e-encryption.ts + index.ts, no @/shared/crypto import) - infrastructure/agents/constants.ts (BUILTIN_SUB_AGENT_IDS/isBuiltinSubAgent) - shared/context-menu-system/examples/FileTreeIntegrationExample.tsx Barrel edit (infrastructure/api/index.ts): drop the dead bitfunAPI collection object, its default export, the GitRepoHistory type re-export, and the projectAPI/gitRepoHistoryAPI imports; keep the 23 re-exports that have real consumers. Sync the eslint examples/** ignore to the deleted example dir. Conservatively retained: - ContextAPI.ts: contextAPI loses its only consumer (ContextManager) but wraps backend session commands (compress_context/save_session_data/...) — cross-layer decision, pruned from bitfunAPI but file kept. - Method-level dead code (~60 methods across RemoteConnectAPI/MiniAppAPI/ SubagentAPI/AgentAPI/etc): TS wrapper dead != Rust handler dead; deferred to a follow-up batch that checks the backend command table per method. Verified: tsc --noEmit introduces no new errors (only a pre-existing, unrelated websocket-adapter GitTrustReport import error remains); eslint src clean; vitest failures pre-exist on baseline (jsdom localStorage env issue). Co-Authored-By: Claude --- src/web-ui/eslint.config.mjs | 1 - .../src/infrastructure/agents/constants.ts | 10 - src/web-ui/src/infrastructure/api/index.ts | 38 +-- .../api/service-api/GitRepoHistoryAPI.ts | 41 --- .../api/service-api/ProjectAPI.ts | 65 ----- .../src/infrastructure/services/api/index.ts | 5 - .../services/business/agentService.ts | 248 ------------------ .../src/infrastructure/services/index.ts | 15 -- .../services/infra/contextManager.ts | 136 ---------- .../infrastructure/services/infra/index.ts | 4 - .../examples/FileTreeIntegrationExample.tsx | 117 --------- .../src/shared/crypto/e2e-encryption.ts | 184 ------------- src/web-ui/src/shared/crypto/index.ts | 9 - 13 files changed, 1 insertion(+), 872 deletions(-) delete mode 100644 src/web-ui/src/infrastructure/agents/constants.ts delete mode 100644 src/web-ui/src/infrastructure/api/service-api/GitRepoHistoryAPI.ts delete mode 100644 src/web-ui/src/infrastructure/api/service-api/ProjectAPI.ts delete mode 100644 src/web-ui/src/infrastructure/services/api/index.ts delete mode 100644 src/web-ui/src/infrastructure/services/business/agentService.ts delete mode 100644 src/web-ui/src/infrastructure/services/index.ts delete mode 100644 src/web-ui/src/infrastructure/services/infra/contextManager.ts delete mode 100644 src/web-ui/src/infrastructure/services/infra/index.ts delete mode 100644 src/web-ui/src/shared/context-menu-system/examples/FileTreeIntegrationExample.tsx delete mode 100644 src/web-ui/src/shared/crypto/e2e-encryption.ts delete mode 100644 src/web-ui/src/shared/crypto/index.ts diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 7b3eaa03fc..56815d1f17 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -19,7 +19,6 @@ export default tseslint.config( 'src/component-library/components/registry.tsx', 'src/component-library/preview/**', 'src/shared/context-system/core/types/**', - 'src/shared/context-menu-system/examples/**', ], }, { diff --git a/src/web-ui/src/infrastructure/agents/constants.ts b/src/web-ui/src/infrastructure/agents/constants.ts deleted file mode 100644 index 0adb97b202..0000000000 --- a/src/web-ui/src/infrastructure/agents/constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Built-in agent ids that are displayed with "Sub-Agent" badge (e.g. Explore, FileFinder). - * Other builtin agents keep the "Built-in" badge. - */ -export const BUILTIN_SUB_AGENT_IDS = ['explore', 'file_finder'] as const; - -export function isBuiltinSubAgent(agentId: string): boolean { - const id = agentId.toLowerCase().replace(/\s+/g, '_'); - return id === 'explore' || id === 'file_finder' || id === 'filefinder'; -} diff --git a/src/web-ui/src/infrastructure/api/index.ts b/src/web-ui/src/infrastructure/api/index.ts index a058377cf3..06e641b535 100644 --- a/src/web-ui/src/infrastructure/api/index.ts +++ b/src/web-ui/src/infrastructure/api/index.ts @@ -21,7 +21,6 @@ import { aiApi } from './service-api/AIApi'; import { toolAPI } from './service-api/ToolAPI'; import { agentAPI } from './service-api/AgentAPI'; import { systemAPI } from './service-api/SystemAPI'; -import { projectAPI } from './service-api/ProjectAPI'; import { diffAPI } from './service-api/DiffAPI'; import { snapshotAPI } from './service-api/SnapshotAPI'; import { globalAPI } from './service-api/GlobalAPI'; @@ -31,7 +30,6 @@ import { permissionAPI } from './service-api/PermissionAPI'; import { pageAPI } from './service-api/PageAPI'; import { gitAPI } from './service-api/GitAPI'; import { gitAgentAPI } from './service-api/GitAgentAPI'; -import { gitRepoHistoryAPI, type GitRepoHistory } from './service-api/GitRepoHistoryAPI'; import { sessionAPI } from './service-api/SessionAPI'; import { i18nAPI } from './service-api/I18nAPI'; import { btwAPI } from './service-api/BtwAPI'; @@ -43,7 +41,7 @@ import { speechAPI } from './service-api/SpeechAPI'; import { worktreeAPI } from './service-api/WorktreeAPI'; // Export API modules -export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, permissionAPI, pageAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, reviewPlatformAPI, insightsApi, tokenUsageStatisticsApi, speechAPI, worktreeAPI }; +export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, permissionAPI, pageAPI, gitAPI, gitAgentAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, reviewPlatformAPI, insightsApi, tokenUsageStatisticsApi, speechAPI, worktreeAPI }; export { TokenUsageStatisticsUnavailableError } from './tokenUsageStatisticsApi'; export * from './service-api/ReviewPlatformAPI'; export type { @@ -58,38 +56,4 @@ export type { } from './tokenUsageStatisticsApi'; // Export types -export type { GitRepoHistory }; export type { CheckForUpdatesResponse } from './service-api/SystemAPI'; - -// BitFun API collection: a single access point for all API modules. -export const bitfunAPI = { - workspace: workspaceAPI, - config: configAPI, - ai: aiApi, - tool: toolAPI, - agent: agentAPI, - system: systemAPI, - project: projectAPI, - diff: diffAPI, - snapshot: snapshotAPI, - global: globalAPI, - context: contextAPI, - cron: cronAPI, - permission: permissionAPI, - pages: pageAPI, - git: gitAPI, - gitAgent: gitAgentAPI, - gitRepoHistory: gitRepoHistoryAPI, - session: sessionAPI, - i18n: i18nAPI, - btw: btwAPI, - editorAi: editorAiAPI, - reviewPlatform: reviewPlatformAPI, - insights: insightsApi, - tokenUsageStatistics: tokenUsageStatisticsApi, - speech: speechAPI, - worktree: worktreeAPI, -}; - -// Default export -export default bitfunAPI; diff --git a/src/web-ui/src/infrastructure/api/service-api/GitRepoHistoryAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GitRepoHistoryAPI.ts deleted file mode 100644 index 3d58a89e29..0000000000 --- a/src/web-ui/src/infrastructure/api/service-api/GitRepoHistoryAPI.ts +++ /dev/null @@ -1,41 +0,0 @@ - - -import { api } from './ApiClient'; -import { createTauriCommandError } from '../errors/TauriCommandError'; - - -export interface GitRepoHistory { - url: string; - lastUsed: string; - localPath?: string; -} - - -export class GitRepoHistoryAPI { - - async saveGitRepoHistory(repos: GitRepoHistory[]): Promise { - try { - await api.invoke('save_git_repo_history', { - request: { repos } - }); - } catch (error) { - throw createTauriCommandError('save_git_repo_history', error, { repos }); - } - } - - - async loadGitRepoHistory(): Promise { - try { - return await api.invoke('load_git_repo_history', { - request: {} - }); - } catch (error) { - throw createTauriCommandError('load_git_repo_history', error); - } - } -} - - -export const gitRepoHistoryAPI = new GitRepoHistoryAPI(); - - diff --git a/src/web-ui/src/infrastructure/api/service-api/ProjectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ProjectAPI.ts deleted file mode 100644 index 3eaa5fa2bb..0000000000 --- a/src/web-ui/src/infrastructure/api/service-api/ProjectAPI.ts +++ /dev/null @@ -1,65 +0,0 @@ - - -import { api } from './ApiClient'; -import { createTauriCommandError } from '../errors/TauriCommandError'; - - -export class ProjectAPI { - - async analyzeProject(path: string, options?: any): Promise { - try { - return await api.invoke('analyze_project', { - request: { path, options } - }); - } catch (error) { - throw createTauriCommandError('analyze_project', error, { path, options }); - } - } - - - async getProjectStructure(path: string): Promise { - try { - return await api.invoke('get_project_structure', { - request: { path } - }); - } catch (error) { - throw createTauriCommandError('get_project_structure', error, { path }); - } - } - - - async getDependencyGraph(path: string): Promise { - try { - return await api.invoke('get_dependency_graph', { - request: { path } - }); - } catch (error) { - throw createTauriCommandError('get_dependency_graph', error, { path }); - } - } - - - async searchCode(query: string, options?: any): Promise { - try { - return await api.invoke('search_code', { - request: { query, options } - }); - } catch (error) { - throw createTauriCommandError('search_code', error, { query, options }); - } - } - - - async clearProjectCache(workspacePath: string): Promise { - try { - await api.invoke('clear_project_cache', { - request: { workspacePath } - }); - } catch (error) { - throw createTauriCommandError('clear_project_cache', error, { workspacePath }); - } - } -} - - -export const projectAPI = new ProjectAPI(); \ No newline at end of file diff --git a/src/web-ui/src/infrastructure/services/api/index.ts b/src/web-ui/src/infrastructure/services/api/index.ts deleted file mode 100644 index ac125dc042..0000000000 --- a/src/web-ui/src/infrastructure/services/api/index.ts +++ /dev/null @@ -1,5 +0,0 @@ - - -export { default as aiService } from './aiService'; -export { default as contextService } from './contextService'; - diff --git a/src/web-ui/src/infrastructure/services/business/agentService.ts b/src/web-ui/src/infrastructure/services/business/agentService.ts deleted file mode 100644 index e7a1a35190..0000000000 --- a/src/web-ui/src/infrastructure/services/business/agentService.ts +++ /dev/null @@ -1,248 +0,0 @@ - - -import { createLogger } from '../../../shared/utils/logger'; -import { agentAPI } from '../../api'; -import { i18nService } from '@/infrastructure/i18n'; - -const logger = createLogger('AgentService'); - - -type AgentType = 'project_qa' | 'requirement_clarification' | 'core'; - -export interface AgentResponse { - content: string; - metadata: Record; -} - -export interface AgentCallOptions { - agentType: AgentType; - message: string; - workspacePath?: string; -} - - -export interface AgentExecutionRequest { - agent_type: string; - prompt: string; - model_name?: string; - workspace_path?: string; - context?: Record; - verbose?: boolean; -} - - -class SessionManager { - private sessions = new Map(); // workspacePath::agentType -> sessionId - - private buildKey(agentType: string, workspacePath: string): string { - return `${workspacePath}::${agentType}`; - } - - getSession(agentType: string, workspacePath: string): string | undefined { - return this.sessions.get(this.buildKey(agentType, workspacePath)); - } - - setSession(agentType: string, workspacePath: string, sessionId: string): void { - this.sessions.set(this.buildKey(agentType, workspacePath), sessionId); - } - - deleteSession(agentType: string, workspacePath: string): void { - this.sessions.delete(this.buildKey(agentType, workspacePath)); - } - - clear(): void { - this.sessions.clear(); - } -} - -export class AgentService { - private static sessionManager = new SessionManager(); - - - static async getOrCreateSession(agentType: string, workspacePath: string, modelName?: string): Promise { - - const existingSessionId = this.sessionManager.getSession(agentType, workspacePath); - if (existingSessionId) { - logger.debug(`Using existing session: ${existingSessionId}`); - return existingSessionId; - } - - - logger.info(`Creating new session: ${agentType}`); - - try { - const response = await agentAPI.createSession({ - sessionName: `${agentType}-session-${Date.now()}`, - agentType, - workspacePath, - config: { - modelName, - enableTools: true, - safeMode: true, - autoCompact: true, - enableContextCompression: true, - } - }); - this.sessionManager.setSession(agentType, workspacePath, response.sessionId); - logger.info(`Session created: ${response.sessionId}`); - return response.sessionId; - } catch (error) { - logger.error('Failed to create session', error); - throw error; - } - } - - - static async executeAgentTaskStream( - request: AgentExecutionRequest, - callbacks: { - onModelRoundStart?: (event: any) => void; - onTextChunk?: (event: any) => void; - onToolCall?: (event: any) => void; - onToolResult?: (event: any) => void; - onToolConfirmation?: (event: any) => void; - onProgress?: (event: any) => void; - onComplete?: (event: any) => void; - onError?: (error: any) => void; - } - ): Promise { - logger.info('Executing agent task flow', { - agentType: request.agent_type, - hasContext: !!request.context - }); - - try { - - const workspacePath = request.workspace_path; - if (!workspacePath) { - throw new Error('Workspace path is required to start an agent task'); - } - const sessionId = await this.getOrCreateSession(request.agent_type, workspacePath, request.model_name); - - - const unlistenFunctions: Array<() => void> = []; - - - if (callbacks.onTextChunk) { - const unlisten = await agentAPI.onTextChunk((event) => { - if (event.sessionId === sessionId) { - callbacks.onTextChunk?.(event); - } - }); - unlistenFunctions.push(unlisten); - } - - - if (callbacks.onModelRoundStart) { - const unlisten = await agentAPI.onModelRoundStarted((event) => { - if (event.sessionId === sessionId) { - callbacks.onModelRoundStart?.(event); - } - }); - unlistenFunctions.push(unlisten); - } - - - if (callbacks.onToolCall || callbacks.onToolResult || callbacks.onToolConfirmation) { - const unlisten = await agentAPI.onToolEvent((event) => { - if (event.sessionId === sessionId) { - const toolEvent = event.toolEvent; - - - if (toolEvent.Started || toolEvent.EarlyDetected) { - callbacks.onToolCall?.(toolEvent); - } else if (toolEvent.Completed || toolEvent.Failed) { - callbacks.onToolResult?.(toolEvent); - } else if (toolEvent.ConfirmationNeeded) { - callbacks.onToolConfirmation?.(toolEvent); - } else if (toolEvent.Progress || toolEvent.StreamChunk) { - callbacks.onProgress?.(toolEvent); - } - } - }); - unlistenFunctions.push(unlisten); - } - - - if (callbacks.onComplete) { - const unlisten = await agentAPI.onDialogTurnCompleted((event) => { - if (event.sessionId === sessionId) { - callbacks.onComplete?.(event); - - unlistenFunctions.forEach(fn => fn()); - } - }); - unlistenFunctions.push(unlisten); - } - - - await agentAPI.startDialogTurn({ - sessionId, - userInput: request.prompt, - agentType: request.agent_type, - workspacePath, - }); - - - return sessionId; - } catch (error) { - logger.error('Agent task flow failed', error); - callbacks.onError?.(error); - throw error; - } - } - - - static async cancelAgentTask(taskId: string): Promise { - try { - await agentAPI.cancelSession(taskId); - logger.info(`Task cancelled: ${taskId}`); - } catch (error) { - logger.error('Failed to cancel task', error); - throw error; - } - } - - - static async getAgentHealth(agentType: AgentType): Promise<{ healthy: boolean; name: string; description: string }> { - return { - healthy: true, - name: this.getAgentDisplayName(agentType), - description: this.getAgentDescription(agentType) - }; - } - - - private static getAgentDisplayName(agentType: AgentType): string { - const nameMap: Record = { - 'project_qa': i18nService.t('common:agents.projectQa.name'), - 'requirement_clarification': i18nService.t('common:agents.requirementClarification.name'), - 'core': i18nService.t('common:agents.core.name') - }; - return nameMap[agentType] || agentType; - } - - - private static getAgentDescription(agentType: AgentType): string { - const descMap: Record = { - 'project_qa': i18nService.t('common:agents.projectQa.description'), - 'requirement_clarification': i18nService.t('common:agents.requirementClarification.description'), - 'core': i18nService.t('common:agents.core.description') - }; - return descMap[agentType] || i18nService.t('common:agents.general.description'); - } - - - - static requiresSpecialVisualization(agentType: AgentType, metadata?: Record): boolean { - if (agentType === 'requirement_clarification') { - - return !!(metadata?.interactive_sections && Array.isArray(metadata.interactive_sections)); - } - - return false; - } - - -} -export default AgentService; diff --git a/src/web-ui/src/infrastructure/services/index.ts b/src/web-ui/src/infrastructure/services/index.ts deleted file mode 100644 index be57530f1b..0000000000 --- a/src/web-ui/src/infrastructure/services/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Core services unified exports. - * - * Service layering: API → Business → Infrastructure - */ - -// API layer: external IO and data access -export * from './api/aiService'; - -// Business layer: domain logic and orchestration -export * from './business/agentService'; -export * from './business/workspaceManager'; - -// Infrastructure layer: low-level technical services -export * from './infra/contextManager'; diff --git a/src/web-ui/src/infrastructure/services/infra/contextManager.ts b/src/web-ui/src/infrastructure/services/infra/contextManager.ts deleted file mode 100644 index 9ee643ea48..0000000000 --- a/src/web-ui/src/infrastructure/services/infra/contextManager.ts +++ /dev/null @@ -1,136 +0,0 @@ - - -import { contextAPI } from '../../api'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('ContextManager'); - - -export type { ContextStats, SessionMetadata, StorageStats } from '../../api/service-api/ContextAPI'; -import type { ContextStats, SessionMetadata, StorageStats } from '../../api/service-api/ContextAPI'; - -export class ContextManager { - - async compressContext(): Promise { - try { - return await contextAPI.compressContext(); - } catch (error) { - log.error('Failed to compress context', error); - throw new Error(`Context compression failed: ${error}`); - } - } - - - async getContextStats(): Promise { - try { - return await contextAPI.getContextStats(); - } catch (error) { - log.error('Failed to get context stats', error); - throw new Error(`Failed to get context stats: ${error}`); - } - } - - - async clearContext(): Promise { - try { - return await contextAPI.clearContext(); - } catch (error) { - log.error('Failed to clear context', error); - throw new Error(`Context clear failed: ${error}`); - } - } - - - async saveSessionData(sessionData: any): Promise { - try { - return await contextAPI.saveSessionData(sessionData); - } catch (error) { - log.error('Failed to save session data', error); - throw new Error(`Session save failed: ${error}`); - } - } - - - async loadSessionData(sessionId: string): Promise { - try { - return await contextAPI.loadSessionData(sessionId); - } catch (error) { - log.error('Failed to load session data', { sessionId, error }); - throw new Error(`Session load failed: ${error}`); - } - } - - - async listSessions(includeArchived: boolean = false): Promise { - try { - return await contextAPI.listSessions(includeArchived); - } catch (error) { - log.error('Failed to list sessions', { includeArchived, error }); - throw new Error(`Failed to list sessions: ${error}`); - } - } - - - async searchSessions(query: string, tags?: string[]): Promise { - try { - return await contextAPI.searchSessions(query, tags); - } catch (error) { - log.error('Failed to search sessions', { query, tags, error }); - throw new Error(`Failed to search sessions: ${error}`); - } - } - - - async deleteSession(sessionId: string): Promise { - try { - return await contextAPI.deleteSession(sessionId); - } catch (error) { - log.error('Failed to delete session', { sessionId, error }); - throw new Error(`Failed to delete session: ${error}`); - } - } - - - async archiveSession(sessionId: string): Promise { - try { - return await contextAPI.archiveSession(sessionId); - } catch (error) { - log.error('Failed to archive session', { sessionId, error }); - throw new Error(`Failed to archive session: ${error}`); - } - } - - - async exportSession(sessionId: string, exportPath: string): Promise { - try { - return await contextAPI.exportSession(sessionId, exportPath); - } catch (error) { - log.error('Failed to export session', { sessionId, exportPath, error }); - throw new Error(`Failed to export session: ${error}`); - } - } - - - async importSession(importPath: string): Promise { - try { - return await contextAPI.importSession(importPath); - } catch (error) { - log.error('Failed to import session', { importPath, error }); - throw new Error(`Failed to import session: ${error}`); - } - } - - - async getStorageStats(): Promise { - try { - return await contextAPI.getStorageStats(); - } catch (error) { - log.error('Failed to get storage stats', error); - throw new Error(`Failed to get storage stats: ${error}`); - } - } -} - - -export const contextManager = new ContextManager(); - diff --git a/src/web-ui/src/infrastructure/services/infra/index.ts b/src/web-ui/src/infrastructure/services/infra/index.ts deleted file mode 100644 index 592d8da905..0000000000 --- a/src/web-ui/src/infrastructure/services/infra/index.ts +++ /dev/null @@ -1,4 +0,0 @@ - - -export { default as contextManager } from './contextManager'; - diff --git a/src/web-ui/src/shared/context-menu-system/examples/FileTreeIntegrationExample.tsx b/src/web-ui/src/shared/context-menu-system/examples/FileTreeIntegrationExample.tsx deleted file mode 100644 index bd8d2f0ad8..0000000000 --- a/src/web-ui/src/shared/context-menu-system/examples/FileTreeIntegrationExample.tsx +++ /dev/null @@ -1,117 +0,0 @@ - - -import React, { useEffect } from 'react'; -import { initContextMenuSystem } from '../init'; - - - - - - -export function initializeFileTreeContextMenu() { - initContextMenuSystem({ - registerBuiltinCommands: true, - registerBuiltinProviders: true, - debug: process.env.NODE_ENV === 'development' - }); -} - - - - - - - - - - - -import { globalEventBus } from '../../../infrastructure/event-bus'; - -export function FileTreeEventHandler() { - useEffect(() => { - - const unsubOpen = globalEventBus.on('file:open', (data: any) => { - - }); - - - const unsubNewFile = globalEventBus.on('file:new-file', (data: any) => { - - - - - }); - - - const unsubNewFolder = globalEventBus.on('file:new-folder', (data: any) => { - - }); - - - const unsubRename = globalEventBus.on('file:rename', (data: any) => { - - - - - }); - - - const unsubDelete = globalEventBus.on('file:delete', (data: any) => { - - - - - }); - - - const unsubReveal = globalEventBus.on('file:reveal', (data: any) => { - - - }); - - - const unsubTerminal = globalEventBus.on('terminal:open-at-path', (data: any) => { - - }); - - - return () => { - unsubOpen(); - unsubNewFile(); - unsubNewFolder(); - unsubRename(); - unsubDelete(); - unsubReveal(); - unsubTerminal(); - }; - }, []); - - return null; -} - - - -export function FileTreeWithContextMenuExample() { - - useEffect(() => { - initializeFileTreeContextMenu(); - }, []); - - return ( -
- - - - - -
- ); -} - - - - - - - diff --git a/src/web-ui/src/shared/crypto/e2e-encryption.ts b/src/web-ui/src/shared/crypto/e2e-encryption.ts deleted file mode 100644 index 6e5fad1328..0000000000 --- a/src/web-ui/src/shared/crypto/e2e-encryption.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * End-to-end encryption for Remote Connect using Web Crypto API. - * - * Key exchange: X25519 ECDH (Chrome 113+, Safari 17+). - * Symmetric encryption: AES-256-GCM. - * - * For older browsers that lack X25519 support in Web Crypto, this module - * falls back to the @noble/curves library (must be installed separately). - */ - -const ALGO_AES = 'AES-GCM'; -const KEY_LENGTH = 256; -const NONCE_LENGTH = 12; - -// X25519 is available in Web Crypto starting Chrome 113 / Safari 17. -// We detect support at runtime and fall back to @noble/curves if needed. - -let _useNobleFallback: boolean | null = null; - -async function supportsWebCryptoX25519(): Promise { - if (_useNobleFallback !== null) return !_useNobleFallback; - try { - await crypto.subtle.generateKey( - { name: 'X25519' } as any, - true, - ['deriveKey'], - ); - _useNobleFallback = false; - return true; - } catch { - _useNobleFallback = true; - return false; - } -} - -// ── Key types ────────────────────────────────────────────────────── - -export interface E2EKeyPair { - publicKey: Uint8Array; - /** Opaque handle — either a CryptoKeyPair or noble private key bytes. */ - _internal: any; -} - -// ── Key generation ───────────────────────────────────────────────── - -export async function generateKeyPair(): Promise { - if (await supportsWebCryptoX25519()) { - return generateKeyPairWebCrypto(); - } - return generateKeyPairNoble(); -} - -async function generateKeyPairWebCrypto(): Promise { - const keyPair = await crypto.subtle.generateKey( - { name: 'X25519' } as any, - true, - ['deriveKey'], - ); - const rawPub = await crypto.subtle.exportKey('raw', (keyPair as any).publicKey); - return { - publicKey: new Uint8Array(rawPub), - _internal: keyPair, - }; -} - -async function generateKeyPairNoble(): Promise { - const { x25519 } = await import('@noble/curves/ed25519'); - const privateKey = crypto.getRandomValues(new Uint8Array(32)); - const publicKey = x25519.getPublicKey(privateKey); - return { - publicKey, - _internal: privateKey, - }; -} - -// ── Shared secret derivation ─────────────────────────────────────── - -export async function deriveSharedSecret( - keyPair: E2EKeyPair, - peerPublicKey: Uint8Array, -): Promise { - if (await supportsWebCryptoX25519()) { - return deriveSharedSecretWebCrypto(keyPair, peerPublicKey); - } - return deriveSharedSecretNoble(keyPair, peerPublicKey); -} - -async function deriveSharedSecretWebCrypto( - keyPair: E2EKeyPair, - peerPublicKey: Uint8Array, -): Promise { - const peerKey = await crypto.subtle.importKey( - 'raw', - peerPublicKey, - { name: 'X25519' } as any, - true, - [], - ); - return crypto.subtle.deriveKey( - { name: 'X25519', public: peerKey } as any, - (keyPair._internal as CryptoKeyPair).privateKey, - { name: ALGO_AES, length: KEY_LENGTH }, - false, - ['encrypt', 'decrypt'], - ); -} - -async function deriveSharedSecretNoble( - keyPair: E2EKeyPair, - peerPublicKey: Uint8Array, -): Promise { - const { x25519 } = await import('@noble/curves/ed25519'); - const sharedBytes = x25519.getSharedSecret(keyPair._internal as Uint8Array, peerPublicKey); - return crypto.subtle.importKey( - 'raw', - sharedBytes, - { name: ALGO_AES, length: KEY_LENGTH }, - false, - ['encrypt', 'decrypt'], - ); -} - -// ── Encrypt / Decrypt ────────────────────────────────────────────── - -export async function encrypt( - sharedKey: CryptoKey, - plaintext: string, -): Promise<{ data: string; nonce: string }> { - const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); - const encoded = new TextEncoder().encode(plaintext); - const ciphertext = await crypto.subtle.encrypt( - { name: ALGO_AES, iv: nonce }, - sharedKey, - encoded, - ); - return { - data: uint8ToBase64(new Uint8Array(ciphertext)), - nonce: uint8ToBase64(nonce), - }; -} - -export async function decrypt( - sharedKey: CryptoKey, - dataBase64: string, - nonceBase64: string, -): Promise { - const ciphertext = base64ToUint8(dataBase64); - const nonce = base64ToUint8(nonceBase64); - const plainBuffer = await crypto.subtle.decrypt( - { name: ALGO_AES, iv: nonce }, - sharedKey, - ciphertext, - ); - return new TextDecoder().decode(plainBuffer); -} - -// ── Public key encoding helpers ──────────────────────────────────── - -export function publicKeyToBase64(key: Uint8Array): string { - return uint8ToBase64(key); -} - -export function base64ToPublicKey(b64: string): Uint8Array { - return base64ToUint8(b64); -} - -// ── Base64 utilities ─────────────────────────────────────────────── - -function uint8ToBase64(bytes: Uint8Array): string { - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -function base64ToUint8(b64: string): Uint8Array { - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} diff --git a/src/web-ui/src/shared/crypto/index.ts b/src/web-ui/src/shared/crypto/index.ts deleted file mode 100644 index 59632091c9..0000000000 --- a/src/web-ui/src/shared/crypto/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - generateKeyPair, - deriveSharedSecret, - encrypt, - decrypt, - publicKeyToBase64, - base64ToPublicKey, -} from './e2e-encryption'; -export type { E2EKeyPair } from './e2e-encryption'; From 54f51a0c9aef254ea27e5e28588a1d35a999d76e Mon Sep 17 00:00:00 2001 From: weishao Date: Mon, 24 Aug 2026 19:53:04 +0800 Subject: [PATCH 3/5] fix(web-ui): declare Peer-Device-Mode owners and close adapter fence gaps Address PR #2428 review (CHANGES_REQUESTED): - Declare LOCAL_ONLY owners for i18n/announcement/companion-pet/insights/ IDE-control/browser/webview/devtools/desktop-pet commands routed to peer without an owner; cross-device routing regressed controller app-shell state. - Add SIDE_EFFECTING_GET_COMMANDS so get_pending/get_announcement_tips (scheduler-mutating reads) are never auto-retried by the peer read path. - Add no-restricted-syntax ImportExpression selector to the ESLint fence so dynamic import('@tauri-apps/api/core') bypasses fail the build too. - Migrate all ~30 pre-existing dynamic-import sites to api.invoke (15 files); each command's peer-vs-local owner is declared to preserve behavior. - FileContextImpl: fs_exists -> check_path_exists (peer-routed, CLI-peer supported) so file-tree path checks resolve on the rendered surface. - PanelController: route report_ide_control_result success branch through api.invoke so both branches use the same LOCAL_ONLY transport. Verified: eslint src -> 0 errors; peer-device-adapter.test.ts 39/39 passed. Co-Authored-By: Claude --- src/web-ui/eslint.config.mjs | 19 +++++ src/web-ui/src/app/App.tsx | 7 +- .../AgentCompanionDesktopPet.tsx | 15 ++-- src/web-ui/src/app/layout/AppLayout.tsx | 8 +- .../app/scenes/agents/hooks/useAgentsList.ts | 4 +- .../browser/useEmbeddedBrowserWebview.ts | 20 ++--- .../profile/views/AssistantDefaultsPage.tsx | 4 +- .../app/services/agentCompanionPetCommands.ts | 8 +- .../tool-cards/ComputerUseToolCard.tsx | 4 +- .../flow_chat/tool-cards/TerminalToolCard.tsx | 4 +- .../api/adapters/peer-device-adapter.test.ts | 72 ++++++++++++++++ .../api/adapters/peer-device-adapter.ts | 85 +++++++++++++++++++ .../config/components/SessionConfig.tsx | 24 ++---- .../services/AgentCompanionWindowService.ts | 4 +- .../infrastructure/debug/useDebugInspector.ts | 7 +- .../flowChatDiagnosticsTransport.ts | 4 +- .../core/ProjectDetector.ts | 5 +- .../core/types/FileContextImpl.tsx | 4 +- .../services/ide-control/PanelController.ts | 23 ++--- .../tools/editor/components/CodeEditor.tsx | 7 +- 20 files changed, 241 insertions(+), 87 deletions(-) diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 56815d1f17..475d741296 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -18,6 +18,10 @@ export default tseslint.config( 'src/**/*.example.tsx', 'src/component-library/components/registry.tsx', 'src/component-library/preview/**', + // Pre-existing legacy: context-system type impls use class components + // with hooks and other legacy patterns. Kept out of lint to avoid + // unrelated churn; the dynamic-import fence still covers the rest of + // src/**. FileContextImpl.tsx here is migrated to api.invoke. 'src/shared/context-system/core/types/**', ], }, @@ -54,6 +58,21 @@ export default tseslint.config( ], }, ], + // no-restricted-imports only covers static ImportDeclaration in ESLint 9; + // dynamic `import('@tauri-apps/api/core')` to grab `invoke` bypasses it. + // Block the same surface with an ImportExpression selector so a future + // dynamic-import bypass fails the build too. Same ignores (adapters/** + + // PeerHostInvokeBridge) apply via this block's ignores; exceptions must be + // added with an owner comment, like the static rule. + 'no-restricted-syntax': [ + 'error', + { + selector: "ImportExpression[source.value='@tauri-apps/api/core']", + message: + '业务命令必须经 api.invoke(ApiClient) 统一适配层,不可动态 import invoke。' + + '如需直连平台 invoke,放到 adapters/ 内并经 api 暴露。', + }, + ], }, }, { diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 889bd4b6ff..b2fd17dd40 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -14,6 +14,7 @@ import { SessionUsageModal } from '../flow_chat/components/usage/SessionUsageMod import { createLogger } from '@/shared/utils/logger'; import { startupTrace } from '@/shared/utils/startupTrace'; import { isTauriRuntime } from '@/infrastructure/runtime'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useWorkspaceContext } from '../infrastructure/contexts/WorkspaceContext'; import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; import { useDebugInspector } from '@/infrastructure/debug/useDebugInspector'; @@ -225,8 +226,7 @@ function App() { mainWindowShownRef.current = true; try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); log.debug('Main window shown', { reason }); startupTrace.markPhase('main_window_shown', { reason }); window.dispatchEvent(new CustomEvent('bitfun:main-window-shown', { detail: { reason } })); @@ -663,8 +663,7 @@ function App() { await openAgentCompanionSession(sessionId); try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to show main window from Agent companion bubble', { sessionId, diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 3f82a5dfa3..4f0e21e6e5 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { emit, listen } from '@tauri-apps/api/event'; import { cursorPosition, getCurrentWindow } from '@tauri-apps/api/window'; import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { ChatInputPixelPet, type ChatInputPixelPetMood } from '@/flow_chat/components/ChatInputPixelPet'; import type { ChatInputPetMood } from '@/flow_chat/utils/chatInputPetMood'; import type { @@ -412,11 +413,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { return; } - void import('@tauri-apps/api/core') - .then(({ invoke }) => invoke('resize_agent_companion_desktop_pet', { + void api.invoke('resize_agent_companion_desktop_pet', { width: nextWidth, height: nextHeight, - })) + }) .catch(error => { log.warn('Failed to resize Agent companion window', error); }); @@ -568,8 +568,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { const showMainWindowFromPet = useCallback(async () => { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to show main window from Agent companion pet', error); } @@ -824,12 +823,8 @@ export const AgentCompanionDesktopPet: React.FC = () => { const openTaskSession = async (task: AgentCompanionTaskStatus) => { try { - const [{ invoke }, { emit }] = await Promise.all([ - import('@tauri-apps/api/core'), - import('@tauri-apps/api/event'), - ]); await emit('agent-companion://open-session', { sessionId: task.sessionId }); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to open Agent companion task session', { sessionId: task.sessionId, diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 32d94bd012..e0e98bc122 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -37,6 +37,7 @@ import { useSessionModeStore } from '../stores/sessionModeStore'; import { isMacOSDesktopRuntime } from '@/infrastructure/runtime'; import { flowChatSessionConfigForWorkspace } from '../utils/projectSessionWorkspace'; import { notificationService } from '@/shared/notification-system'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { AppearanceBackgroundMediaLayer, appearanceRuntime, useAppearance } from '@/infrastructure/appearance'; import './AppLayout.scss'; @@ -445,10 +446,7 @@ const AppLayout: React.FC = ({ className = '' }) => { try { // Both macOS and Windows/Linux: Rust intercepts the native close request // and emits this event. We decide hide vs quit; persist interrupted turns only on quit. - const [{ listen }, { invoke }] = await Promise.all([ - import('@tauri-apps/api/event'), - import('@tauri-apps/api/core'), - ]); + const { listen } = await import('@tauri-apps/api/event'); const persistInterruptedTurnsForExit = async () => { try { @@ -466,7 +464,7 @@ const AppLayout: React.FC = ({ className = '' }) => { if (isMacOS) { // macOS always hides to keep the app alive in the dock. try { - await invoke('hide_main_window_after_close_request'); + await api.invoke('hide_main_window_after_close_request'); } catch (error) { log.error('Failed to hide main window after close request', error); } diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index 70ec6cb3a5..d20c573f57 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { TFunction } from 'i18next'; import { agentAPI, type ModeInfo } from '@/infrastructure/api/service-api/AgentAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { AgentSource } from '@/infrastructure/api/service-api/CustomAgentAPI'; import { SubagentAPI, type SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; @@ -183,8 +184,7 @@ export function useAgentsList({ const fetchTools = async (): Promise => { try { - const { invoke } = await import('@tauri-apps/api/core'); - return await invoke('get_all_tools_info'); + return await api.invoke('get_all_tools_info'); } catch { return []; } diff --git a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts index 57698ca972..d2c343a666 100644 --- a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript'; import { STREAM_RENDER_OPTIMIZATION_SCRIPT } from './browserStreamPerformanceScript'; import { validateUrl } from './browserUrlCheck'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; const WEBVIEW_RESIZE_DEBOUNCE_MS = 160; const WEBVIEW_BOUNDS_EPSILON = 1; @@ -109,8 +110,7 @@ function normalizeUrl(raw: string, defaultUrl: string): string { } async function evalWebview(label: string, script: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_eval', { request: { label, script } }); + await api.invoke('browser_webview_eval', { request: { label, script } }); } async function injectBrowserPageScripts(label: string): Promise { @@ -118,18 +118,15 @@ async function injectBrowserPageScripts(label: string): Promise { } async function navigateWebview(label: string, url: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_navigate', { request: { label, url } }); + await api.invoke('browser_webview_navigate', { request: { label, url } }); } async function reloadWebview(label: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_reload', { request: { label } }); + await api.invoke('browser_webview_reload', { request: { label } }); } async function setWebviewBounds(label: string, bounds: WebviewBounds): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_set_bounds', { + await api.invoke('browser_webview_set_bounds', { request: { label, x: bounds.left, @@ -141,11 +138,8 @@ async function setWebviewBounds(label: string, bounds: WebviewBounds): Promise { - const [{ invoke }, { Webview }] = await Promise.all([ - import('@tauri-apps/api/core'), - import('@tauri-apps/api/webview'), - ]); - await invoke('browser_webview_create', { + const { Webview } = await import('@tauri-apps/api/webview'); + await api.invoke('browser_webview_create', { request: { label, url, diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx index 8be42bf7ee..7c907082ed 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx @@ -14,6 +14,7 @@ import { GalleryZone } from '@/app/components'; import '@/app/components/GalleryLayout/GalleryLayout.scss'; import { Switch } from '@/component-library'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { AgentProfileConfigItem, ModeSkillInfo } from '@/infrastructure/config/types'; import { buildSkillCoverageSourceMap, @@ -183,10 +184,9 @@ const AssistantDefaultsPage: React.FC = () => { (async () => { setLoading(true); try { - const { invoke } = await import('@tauri-apps/api/core'); const [modeConf, tools, skillList, servers] = await Promise.all([ configAPI.getAgentProfileConfig(ASSISTANT_MODE_ID).catch(() => null as AgentProfileConfigItem | null), - invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), + api.invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }).catch(() => [] as ModeSkillInfo[]), MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), ]); diff --git a/src/web-ui/src/app/services/agentCompanionPetCommands.ts b/src/web-ui/src/app/services/agentCompanionPetCommands.ts index f8fae0d492..d9ae05c8a2 100644 --- a/src/web-ui/src/app/services/agentCompanionPetCommands.ts +++ b/src/web-ui/src/app/services/agentCompanionPetCommands.ts @@ -1,6 +1,7 @@ import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; import { FlowChatStore } from '@/flow_chat/store/FlowChatStore'; import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('AgentCompanionPetCommands'); @@ -46,12 +47,9 @@ async function closeAgentCompanionDesktopPet(): Promise { } async function openAgentCompanionPetSettings(): Promise { - const [{ quickActions }, { invoke }] = await Promise.all([ - import('@/shared/services/ide-control'), - import('@tauri-apps/api/core'), - ]); + const { quickActions } = await import('@/shared/services/ide-control'); quickActions.openSettings('session-personalization'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); log.info('Agent companion settings opened from pet context menu'); } diff --git a/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx index 7938240494..7dd8baa1cc 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx @@ -20,6 +20,7 @@ import { import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; @@ -91,8 +92,7 @@ function isPermissionDeniedError(message: string | null): boolean { } async function openComputerUseSettings(pane: 'accessibility' | 'screen_capture'): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('computer_use_open_system_settings', { request: { pane } }); + await api.invoke('computer_use_open_system_settings', { request: { pane } }); } /** Groups the ~40 ComputerUse actions into a handful of recognizable icons instead of one icon per action. */ diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index a0c95b46aa..591f194d2f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -29,6 +29,7 @@ import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActions'; import { CopyableTextPreview } from '../components/CopyableTextPreview'; import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import './TerminalToolCard.scss'; const log = createLogger('TerminalToolCard'); @@ -420,8 +421,7 @@ export const TerminalToolCard: React.FC = ({ setInterruptRequested(true); try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('cancel_tool', { + await api.invoke('cancel_tool', { request: { toolUseId, reason: 'User cancelled', diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 522c3dfd1b..8f5ee829b4 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -42,6 +42,70 @@ describe('isPeerLocalOnlyCommand', () => { it('keeps native main-window geometry control on the controller computer', () => { expect(isPeerLocalOnlyCommand('set_main_window_transient_geometry')).toBe(true); }); + + it('keeps controller app-shell locale on the controller device', () => { + expect(isPeerLocalOnlyCommand('i18n_get_current_language')).toBe(true); + expect(isPeerLocalOnlyCommand('i18n_set_language')).toBe(true); + expect(isPeerLocalOnlyCommand('i18n_get_supported_languages')).toBe(true); + expect(isPeerLocalOnlyCommand('i18n_get_config')).toBe(true); + expect(isPeerLocalOnlyCommand('i18n_set_config')).toBe(true); + }); + + it('keeps announcement scheduler and state on the controller device', () => { + expect(isPeerLocalOnlyCommand('get_pending_announcements')).toBe(true); + expect(isPeerLocalOnlyCommand('get_announcement_tips')).toBe(true); + expect(isPeerLocalOnlyCommand('mark_announcement_seen')).toBe(true); + expect(isPeerLocalOnlyCommand('dismiss_announcement')).toBe(true); + expect(isPeerLocalOnlyCommand('never_show_announcement')).toBe(true); + expect(isPeerLocalOnlyCommand('trigger_announcement')).toBe(true); + }); + + it('keeps companion-pet import and preview on the controller device', () => { + expect(isPeerLocalOnlyCommand('list_agent_companion_pets')).toBe(true); + expect(isPeerLocalOnlyCommand('import_agent_companion_pet_package')).toBe(true); + expect(isPeerLocalOnlyCommand('delete_agent_companion_pet_package')).toBe(true); + }); + + it('keeps insights generation, progress and report on the controller device', () => { + expect(isPeerLocalOnlyCommand('generate_insights')).toBe(true); + expect(isPeerLocalOnlyCommand('get_latest_insights')).toBe(true); + expect(isPeerLocalOnlyCommand('load_insights_report')).toBe(true); + expect(isPeerLocalOnlyCommand('has_insights_data')).toBe(true); + expect(isPeerLocalOnlyCommand('cancel_insights_generation')).toBe(true); + }); + + it('keeps IDE control result reporting on the controller device', () => { + expect(isPeerLocalOnlyCommand('report_ide_control_result')).toBe(true); + }); + + it('keeps controller browser/webview/devtools/desktop-pet/diagnostics on the controller device', () => { + // These previously hit the local Tauri host via dynamic invoke(); routing + // them to a peer would regress (peer host does not implement them). + expect(isPeerLocalOnlyCommand('browser_control_launch')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_control_list_browsers')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_control_get_status')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_control_restart_with_cdp')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_control_enable_default_cdp')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_create')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_eval')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_navigate')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_reload')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_set_bounds')).toBe(true); + expect(isPeerLocalOnlyCommand('computer_use_get_status')).toBe(true); + expect(isPeerLocalOnlyCommand('debug_devtools_available')).toBe(true); + expect(isPeerLocalOnlyCommand('debug_open_devtools')).toBe(true); + expect(isPeerLocalOnlyCommand('resize_agent_companion_desktop_pet')).toBe(true); + expect(isPeerLocalOnlyCommand('show_agent_companion_desktop_pet')).toBe(true); + expect(isPeerLocalOnlyCommand('hide_agent_companion_desktop_pet')).toBe(true); + expect(isPeerLocalOnlyCommand('append_flow_chat_diagnostics')).toBe(true); + }); + + it('keeps file-tree path checks routed to the peer surface', () => { + // check_path_exists is the one CLI-Peer-supported routed command: the path + // comes from the rendered surface's file tree, so it must stay peer-routed. + expect(isPeerLocalOnlyCommand('check_path_exists')).toBe(false); + expect(peerInvokePriorityFor('check_path_exists')).toBe('high'); + }); }); describe('peerInvokePriorityFor', () => { @@ -109,6 +173,14 @@ describe('peerInvokePriorityFor', () => { expect(isPeerRetryableReadCommand('respond_permission')).toBe(false); }); + it('does not retry side-effecting announcement get_* commands', () => { + // These run the scheduler (mutate app_open_count + persist) and must never + // be auto-retried by the peer read path, where retries would multiply the + // side effect. + expect(isPeerRetryableReadCommand('get_pending_announcements')).toBe(false); + expect(isPeerRetryableReadCommand('get_announcement_tips')).toBe(false); + }); + it('retries only mutations with an explicit host idempotency identity', () => { expect(isPeerRetryableIdempotentMutation('start_dialog_turn', { request: { sessionId: 'session-1', turnId: 'turn-1' }, diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index a391a7b966..3de99b7ee2 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -126,6 +126,76 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'speech_append_audio_chunk', 'speech_finish_input_session', 'speech_cancel_input_session', + // UI locale is controller app-shell state: it writes the controller's config + // file, rebuilds THIS machine's macOS menubar/tray, and drives the UI the user + // is looking at. Routing it to a peer both writes the wrong config and rebuilds + // the wrong machine's chrome (CLI peer returns unsupported). See PR #2428. + 'i18n_get_current_language', + 'i18n_set_language', + 'i18n_get_supported_languages', + 'i18n_get_config', + 'i18n_set_config', + // Announcement cards/scheduler are controller app-shell state. get_pending / + // get_tips trigger the scheduler (mutate app_open_count + persist); seen / + // dismiss / never-show write the controller's announcement state. CLI peer + // returns unsupported. Keeping them LOCAL_ONLY also removes them from peer + // read-retry (see SIDE_EFFECTING_GET_COMMANDS). See scheduler.rs run(). + 'get_pending_announcements', + 'get_announcement_tips', + 'mark_announcement_seen', + 'dismiss_announcement', + 'never_show_announcement', + 'trigger_announcement', + // Companion pets live on the controller's desktop. The import zip is picked by + // a local dialog on A; its absolute path only exists on A. Peer B cannot read + // it, and B's returned spritesheetPath is a B-absolute path A's plugin-fs + // cannot open. CLI peer returns unsupported at dispatch. Keeping these + // LOCAL_ONLY gates Peer Mode (invariant 10: download destinations stay on the + // controller). See PR #2428. + 'list_agent_companion_pets', + 'import_agent_companion_pet_package', + 'delete_agent_companion_pet_package', + // Insights is the controller's own usage report: it reads the controller's + // session history and writes the HTML to the controller's user_data_dir. In + // Peer Mode generate_insights would run on B but listenProgress listens on A, + // openReport opens B's absolute path on A, and the 30s mutation timeout fires + // while B keeps running. Keep it controller-local so report, progress event + // and openPath all land on one machine. (Cross-device insights tracking is + // out of scope; this LOCAL gate is the reviewer-asked fix.) See PR #2428. + 'generate_insights', + 'get_latest_insights', + 'load_insights_report', + 'has_insights_data', + 'cancel_insights_generation', + // IDE control events drive THIS window's panels (window.dispatchEvent). The + // listen is local; the result report must use the same transport on both + // success and error branches so a request never splits across hosts. CLI peer + // returns unsupported. See PR #2428. + 'report_ide_control_result', + // Controller app-shell / local-device commands reached by migrating dynamic + // invoke() sites behind the adapter fence. These previously hit the local + // Tauri host directly; routing them to a peer would be a regression (the peer + // host does not implement them, and they operate on the controller's own + // browser/webview/DevTools/desktop-pet/diagnostics). Declared LOCAL_ONLY so + // api.invoke keeps them on the controller. See PR #2428 (lint fence + dynamic + // import migration). + 'browser_control_launch', + 'browser_control_list_browsers', + 'browser_control_get_status', + 'browser_control_restart_with_cdp', + 'browser_control_enable_default_cdp', + 'browser_webview_create', + 'browser_webview_eval', + 'browser_webview_navigate', + 'browser_webview_reload', + 'browser_webview_set_bounds', + 'computer_use_get_status', + 'debug_devtools_available', + 'debug_open_devtools', + 'resize_agent_companion_desktop_pet', + 'show_agent_companion_desktop_pet', + 'hide_agent_companion_desktop_pet', + 'append_flow_chat_diagnostics', ]); /** @@ -226,7 +296,22 @@ export function isPeerLocalOnlyCommand(command: string): boolean { return LOCAL_ONLY_COMMANDS.has(command); } +/** + * `get_*` commands that run the announcement scheduler (mutate app_open_count + + * persist state). They are NOT side-effect-free reads and must never be + * auto-retried by the peer read path, where a retry would multiply the side + * effect. They are also LOCAL_ONLY, but this guard keeps the contract explicit + * if ownership ever moves back to the peer. See scheduler.rs run(). + */ +const SIDE_EFFECTING_GET_COMMANDS = new Set([ + 'get_pending_announcements', + 'get_announcement_tips', +]); + export function isPeerRetryableReadCommand(command: string): boolean { + if (SIDE_EFFECTING_GET_COMMANDS.has(command)) { + return false; + } return RETRYABLE_READ_COMMANDS.has(command) || command.startsWith('read_') || command.startsWith('list_') || diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 285025c8e3..e4f25d758d 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -34,6 +34,7 @@ import { permissionConfigService, } from '../services/PermissionConfigService'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useNotification, notificationService } from '@/shared/notification-system'; import type { DebugModeConfig, @@ -167,8 +168,7 @@ const SessionSettingsPanels: React.FC = ({ variant } if (!IS_TAURI_DESKTOP) return false; setComputerUseStatusLoading(true); try { - const { invoke } = await import('@tauri-apps/api/core'); - const s = await invoke('computer_use_get_status'); + const s = await api.invoke('computer_use_get_status'); setComputerUseEnabled(s.computerUseEnabled); setComputerUseAccess(s.accessibilityGranted); setComputerUseScreen(s.screenCaptureGranted); @@ -186,9 +186,8 @@ const SessionSettingsPanels: React.FC = ({ variant } if (!IS_TAURI_DESKTOP) return; setBrowserStatusLoading(true); try { - const { invoke } = await import('@tauri-apps/api/core'); const [s, browsers] = await Promise.all([ - invoke<{ + api.invoke<{ cdpAvailable: boolean; defaultCdpSupported: boolean; defaultCdpEnabled: boolean; @@ -198,7 +197,7 @@ const SessionSettingsPanels: React.FC = ({ variant } port: number; pageCount: number; }>('browser_control_get_status', { request: { port: 9222 } }), - invoke<{ options: BrowserControlBrowserOption[] }>('browser_control_list_browsers'), + api.invoke<{ options: BrowserControlBrowserOption[] }>('browser_control_list_browsers'), ]); setBrowserCdpAvailable(s.cdpAvailable); setBrowserDefaultCdpSupported(s.defaultCdpSupported); @@ -585,8 +584,7 @@ const SessionSettingsPanels: React.FC = ({ variant } // Screen Recording) the moment the user opts in, instead of waiting // for the first agent tool call to fail with a permission error. try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('computer_use_request_permissions'); + await api.invoke('computer_use_request_permissions'); } catch (permError) { log.warn('computer_use_request_permissions failed', permError); } @@ -603,8 +601,7 @@ const SessionSettingsPanels: React.FC = ({ variant } const handleComputerUseOpenSettings = async (pane: 'accessibility' | 'screen_capture') => { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('computer_use_open_system_settings', { request: { pane } }); + await api.invoke('computer_use_open_system_settings', { request: { pane } }); } catch (error) { log.error('computer_use_open_system_settings failed', error); notificationService.error(t('messages.saveFailed')); @@ -683,8 +680,7 @@ const SessionSettingsPanels: React.FC = ({ variant } const handleBrowserControlLaunch = async () => { setBrowserControlBusy(true); try { - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke('browser_control_launch', { request: { port: 9222 } }); + const result = await api.invoke('browser_control_launch', { request: { port: 9222 } }); presentBrowserControlLaunchResult(result); await refreshBrowserControlStatus(); } catch (error) { @@ -707,8 +703,7 @@ const SessionSettingsPanels: React.FC = ({ variant } ), { duration: 12000 }, ); - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke( + const result = await api.invoke( 'browser_control_enable_default_cdp', { request: { port: 9222 } }, ); @@ -726,8 +721,7 @@ const SessionSettingsPanels: React.FC = ({ variant } if (!browserRestartPrompt) return; setBrowserControlBusy(true); try { - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke('browser_control_restart_with_cdp', { + const result = await api.invoke('browser_control_restart_with_cdp', { request: { port: 9222 }, }); if (result.success) { diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts index a5ec3657c9..0bcebef2f4 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts @@ -1,5 +1,6 @@ import { isTauriRuntime } from '@/infrastructure/runtime'; import { createLogger } from '@/shared/utils/logger'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { AIExperienceSettings } from './AIExperienceConfigService'; const log = createLogger('AgentCompanionWindowService'); @@ -36,8 +37,7 @@ export async function syncAgentCompanionDesktopWindow( command, displayMode: settings.agent_companion_display_mode, }); - const { invoke } = await import('@tauri-apps/api/core'); - await invoke(command); + await api.invoke(command); if (requestId !== companionDesktopWindowSyncRequestId) { return; } diff --git a/src/web-ui/src/infrastructure/debug/useDebugInspector.ts b/src/web-ui/src/infrastructure/debug/useDebugInspector.ts index 795f2e85b1..f4e37dda1e 100644 --- a/src/web-ui/src/infrastructure/debug/useDebugInspector.ts +++ b/src/web-ui/src/infrastructure/debug/useDebugInspector.ts @@ -10,6 +10,7 @@ */ import { useEffect } from 'react'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { createLogger } from '@/shared/utils/logger'; import { isTauriRuntime } from '@/infrastructure/runtime'; import { @@ -30,8 +31,7 @@ async function loadDevToolsAvailable(): Promise { if (!isTauriRuntime()) return false; try { - const { invoke } = await import('@tauri-apps/api/core'); - return await invoke('debug_devtools_available'); + return await api.invoke('debug_devtools_available'); } catch (error) { log.error('Failed to detect DevTools availability', error); return false; @@ -70,8 +70,7 @@ async function evalInPage(script: string): Promise { /** Open the native webview DevTools window. */ async function openNativeDevTools(): Promise { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('debug_open_devtools'); + await api.invoke('debug_open_devtools'); log.info('Native DevTools opened'); } catch (error) { log.error('Failed to open native DevTools', error); diff --git a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts index c08c68e098..a1e71dcef7 100644 --- a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts +++ b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts @@ -1,4 +1,5 @@ import { isTauriRuntime } from '@/infrastructure/runtime'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; export interface FlowChatDiagnosticTransportEntry { sequence: number; @@ -17,8 +18,7 @@ export async function appendFlowChatDiagnosticEntries( return; } - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('append_flow_chat_diagnostics', { + await api.invoke('append_flow_chat_diagnostics', { request: { entries }, }); } diff --git a/src/web-ui/src/infrastructure/language-detection/core/ProjectDetector.ts b/src/web-ui/src/infrastructure/language-detection/core/ProjectDetector.ts index 2ec9fa63e2..a2f20d5bfb 100644 --- a/src/web-ui/src/infrastructure/language-detection/core/ProjectDetector.ts +++ b/src/web-ui/src/infrastructure/language-detection/core/ProjectDetector.ts @@ -8,6 +8,7 @@ import type { ProjectDetectionPlugin } from '../types'; import { createLogger } from '@/shared/utils/logger'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; const log = createLogger('ProjectDetector'); @@ -230,9 +231,7 @@ class ProjectDetector { private async detectWithBackend(workspacePath: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - - const backendResult = await invoke<{ + const backendResult = await api.invoke<{ languages: string[]; primaryLanguage?: string; fileCount: Record; diff --git a/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx b/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx index e1817b5e83..fa51e4d55d 100644 --- a/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx +++ b/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx @@ -42,7 +42,9 @@ export class FileContextValidator implements ContextValidator<'file'> { async validate(context: FileContext): Promise { try { - const exists = await api.invoke('fs_exists', { path: context.filePath }); + const exists = await api.invoke('check_path_exists', { + request: { path: context.filePath }, + }); if (!exists) { return { diff --git a/src/web-ui/src/shared/services/ide-control/PanelController.ts b/src/web-ui/src/shared/services/ide-control/PanelController.ts index 81e3580424..9c673591bc 100644 --- a/src/web-ui/src/shared/services/ide-control/PanelController.ts +++ b/src/web-ui/src/shared/services/ide-control/PanelController.ts @@ -4,6 +4,7 @@ * Implements a subset of IDE control operations focused on opening/closing panels. */ import { i18nService } from '@/infrastructure/i18n'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { IdeController, IdeControlEvent, @@ -237,17 +238,17 @@ export class PanelController implements IdeController { private sendExecutionResult(requestId: string, success: boolean, message: string): void { - - import('@tauri-apps/api/core').then(({ invoke }) => { - invoke('report_ide_control_result', { - request_id: requestId, - success, - message: success ? message : undefined, - error: success ? undefined : message, - timestamp: Date.now(), - }).catch((error) => { - log.error('Failed to send execution result', error); - }); + // Route through the shared adapter so the success branch uses the same + // transport as the error branch in IdeControlEventBus. report_ide_control_result + // is LOCAL_ONLY, so both branches settle on the controller's local host. + api.invoke('report_ide_control_result', { + request_id: requestId, + success, + message: success ? message : undefined, + error: success ? undefined : message, + timestamp: Date.now(), + }).catch((error) => { + log.error('Failed to send execution result', error); }); } } diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 3eba368dc7..3eef819def 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -24,6 +24,7 @@ import { createLogger } from '@/shared/utils/logger'; import { sendDebugProbe } from '@/shared/utils/debugProbe'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { isSamePath } from '@/shared/utils/pathUtils'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { isPeerDeviceModeActive, PEER_MODE_FILE_SYNC_POLL_MS, @@ -1813,7 +1814,6 @@ const CodeEditor: React.FC = ({ return; } - const { invoke } = await import('@tauri-apps/api/core'); const fileInfo = await fetchFileMetadata(); if (isFileMissingFromMetadata(fileInfo)) { outcome = 'missing-on-disk'; @@ -1841,7 +1841,7 @@ const CodeEditor: React.FC = ({ const bufferBeforeRead = modelRef.current?.getValue(); try { - const hashRes: any = await invoke('get_file_editor_sync_hash', { + const hashRes: any = await api.invoke('get_file_editor_sync_hash', { request: { path: filePath }, }); const diskHash = @@ -2166,10 +2166,9 @@ const CodeEditor: React.FC = ({ try { const { workspaceAPI } = await import('@/infrastructure/api'); - const { invoke } = await import('@tauri-apps/api/core'); const bufferBeforeRead = modelRef.current?.getValue(); try { - const hashRes: any = await invoke('get_file_editor_sync_hash', { + const hashRes: any = await api.invoke('get_file_editor_sync_hash', { request: { path: filePath }, }); const diskHash = From a031ca67870f9fa38bd8becbb497f002b5c9bb24 Mon Sep 17 00:00:00 2001 From: weishao Date: Mon, 24 Aug 2026 20:33:24 +0800 Subject: [PATCH 4/5] fix(peer-host): mirror controller-owned commands into peer deny lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core-boundaries check enforces a one-way ownership boundary: every command the FE adapter marks LOCAL_ONLY must also be refused by each peer host, because an older or non-Web-UI controller can still HostInvoke them. The previous commit added i18n/announcement/companion-pet/insights/IDE- control/browser/webview/devtools/desktop-pet/diagnostics commands to the FE deny list but not to the desktop and CLI peer-host deny lists, so CI's "Check core boundaries" step failed. Add the 37 controller-owned commands to both src/apps/desktop/src/api/peer_host_invoke.rs and src/apps/cli/src/peer_host/deny.rs, grouped with owner comments mirroring the FE adapter. Being unimplemented on the CLI peer is not the boundary — they are refused explicitly. Verified: check-core-boundaries -> passed; check-core-boundaries.test -> 126/126; cargo test -p bitfun-desktop peer_host -> 6/6; cargo test --bin bitfun peer_host -> 79/79. Co-Authored-By: Claude --- src/apps/cli/src/peer_host/deny.rs | 44 ++++++++++++++++ src/apps/desktop/src/api/peer_host_invoke.rs | 55 ++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 16701bec1c..2cfa80db8a 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -113,6 +113,50 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // belongs to the person at this machine, so refuse it explicitly rather // than relying on the command being unimplemented here. "git_trust_repository", + // Controller app-shell state mirrored from the FE deny list. An older or + // non-Web-UI controller can still HostInvoke these onto this peer, so the + // CLI peer host must refuse them independently of the FE optimization. + // Keep in sync with `src/web-ui/.../adapters/peer-device-adapter.ts` + // LOCAL_ONLY_COMMANDS and `src/apps/desktop/src/api/peer_host_invoke.rs`. + // These controller-owned commands are not implemented here either, but + // being unimplemented is not the boundary — refuse explicitly. + "i18n_get_current_language", + "i18n_set_language", + "i18n_get_supported_languages", + "i18n_get_config", + "i18n_set_config", + "get_pending_announcements", + "get_announcement_tips", + "mark_announcement_seen", + "dismiss_announcement", + "never_show_announcement", + "trigger_announcement", + "list_agent_companion_pets", + "import_agent_companion_pet_package", + "delete_agent_companion_pet_package", + "generate_insights", + "get_latest_insights", + "load_insights_report", + "has_insights_data", + "cancel_insights_generation", + "report_ide_control_result", + "browser_control_launch", + "browser_control_list_browsers", + "browser_control_get_status", + "browser_control_restart_with_cdp", + "browser_control_enable_default_cdp", + "browser_webview_create", + "browser_webview_eval", + "browser_webview_navigate", + "browser_webview_reload", + "browser_webview_set_bounds", + "computer_use_get_status", + "debug_devtools_available", + "debug_open_devtools", + "resize_agent_companion_desktop_pet", + "show_agent_companion_desktop_pet", + "hide_agent_companion_desktop_pet", + "append_flow_chat_diagnostics", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index fcc2800946..ba793ae92f 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -145,6 +145,61 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // That decision stays with the person at that machine; a controller can // still read `git_get_repository_trust` and relay the manual command. "git_trust_repository", + // Controller app-shell state mirrored from the FE deny list. An older or + // non-Web-UI controller can still HostInvoke these onto this peer, so the + // peer host must refuse them independently of the FE optimization. Keep in + // sync with `src/web-ui/.../adapters/peer-device-adapter.ts` + // LOCAL_ONLY_COMMANDS and `src/apps/cli/src/peer_host/deny.rs`. + // UI locale writes the controller's config and rebuilds THIS machine's + // macOS menubar/tray; routing it to a peer writes the wrong config. + "i18n_get_current_language", + "i18n_set_language", + "i18n_get_supported_languages", + "i18n_get_config", + "i18n_set_config", + // Announcement scheduler/state: get_pending / get_tips run the scheduler + // (mutate app_open_count + persist); seen / dismiss / never-show write + // controller announcement state. Refused on the peer. + "get_pending_announcements", + "get_announcement_tips", + "mark_announcement_seen", + "dismiss_announcement", + "never_show_announcement", + "trigger_announcement", + // Companion pets live on the controller's desktop; the import zip path is + // picked by a local dialog on the controller and is not readable here. + "list_agent_companion_pets", + "import_agent_companion_pet_package", + "delete_agent_companion_pet_package", + // Insights is the controller's own usage report: it reads the controller's + // session history and writes the HTML to the controller's user_data_dir. + "generate_insights", + "get_latest_insights", + "load_insights_report", + "has_insights_data", + "cancel_insights_generation", + // IDE control events drive the controller window's panels; the result + // report must settle on the controller's transport, not here. + "report_ide_control_result", + // Controller app-shell / local-device commands (browser/webview/DevTools/ + // desktop-pet/diagnostics) operate on the controller's own surfaces. + "browser_control_launch", + "browser_control_list_browsers", + "browser_control_get_status", + "browser_control_restart_with_cdp", + "browser_control_enable_default_cdp", + "browser_webview_create", + "browser_webview_eval", + "browser_webview_navigate", + "browser_webview_reload", + "browser_webview_set_bounds", + "computer_use_get_status", + "debug_devtools_available", + "debug_open_devtools", + "resize_agent_companion_desktop_pet", + "show_agent_companion_desktop_pet", + "hide_agent_companion_desktop_pet", + "append_flow_chat_diagnostics", ]; static PENDING: OnceLock>>> = From 5abe1e2b5156879eba581a52c65bdb22ba1e3df9 Mon Sep 17 00:00:00 2001 From: weishao Date: Mon, 24 Aug 2026 21:17:53 +0800 Subject: [PATCH 5/5] fix(web-ui): point migrated tests at the ApiClient mock surface The dynamic-import migration moved useDebugInspector and agentCompanionPetCommands off `@tauri-apps/api/core` and onto `api.invoke`, but their tests still mocked `@tauri-apps/api/core`, so the mock never intercepted the call. CI "Run web UI tests" failed: - useDebugInspector.test.tsx: expected mocks.invoke to be called with 'debug_devtools_available' but it was called 0 times. - agentCompanionPetCommands.test.ts: api.invoke('show_main_window') hit the real ApiClient (WebSocket connection failed). Repoint both tests at `@/infrastructure/api/service-api/ApiClient` (the established pattern) and flush the post-invoke microtask in useDebugInspector before dispatching keys, since the keydown listener now registers right after the (synchronous) api.invoke resolves rather than after the old dynamic-import microtask. Verified: useDebugInspector 4/4, agentCompanionPetCommands 7/7. Co-Authored-By: Claude --- .../app/services/agentCompanionPetCommands.test.ts | 6 ++++-- .../infrastructure/debug/useDebugInspector.test.tsx | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts b/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts index 0230192476..7dde5dbe95 100644 --- a/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts +++ b/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts @@ -9,8 +9,10 @@ const saveSettingsMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); const openSettingsMock = vi.hoisted(() => vi.fn()); const invokeMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); -vi.mock('@tauri-apps/api/core', () => ({ - invoke: invokeMock, +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { + invoke: invokeMock, + }, })); vi.mock('@/flow_chat/services/FlowChatManager', () => ({ diff --git a/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx b/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx index f9ecdff6f9..29e06ff7ed 100644 --- a/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx +++ b/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx @@ -11,8 +11,10 @@ const mocks = vi.hoisted(() => ({ invoke: vi.fn(), })); -vi.mock('@tauri-apps/api/core', () => ({ - invoke: mocks.invoke, +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { + invoke: mocks.invoke, + }, })); vi.mock('./mainWindowInspector', () => ({ @@ -91,6 +93,10 @@ describe('useDebugInspector', () => { root.render(); }); await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith('debug_devtools_available')); + // loadDevToolsAvailable awaits api.invoke before registering the keydown + // listener; flush the effect's microtask so the handler is attached + // before we dispatch. + await act(async () => { await Promise.resolve(); }); mocks.invoke.mockClear(); const event = dispatchKey({ key: 'F12' }); @@ -105,6 +111,7 @@ describe('useDebugInspector', () => { root.render(); }); await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith('debug_devtools_available')); + await act(async () => { await Promise.resolve(); }); mocks.invoke.mockClear(); const event = dispatchKey({ key: 'i', ctrlKey: true, shiftKey: true });